ちょっと話題の記事

Angular + RxJS + WebSocket で チャットアプリを作る – Subject 利用サンプル

2017.05.08

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

以前、別の記事でAkka製のチャットサーバを作ったのでシンプルなクライアントアプリも作りました。

chat

バージョン情報

パッケージ名 バージョン
@angular/core 4.1.1
bootstrap 4.0.0-alpha.6
rxjs 5.3.1
typescript 2.2.2

WebSocketとSubjectの連携イメージ

RxJSの部品は、今回 Subject を使います。Subjectは、定義したバックエンドのデータソースを Subscribe することができ、なおかつバックエンドに対して next によりデータを送出する能力をもっています。一方でWebSocketはコネクションが成立した後、 onmessage イベントに対するハンドラを登録してデータ受信時のアクションを定め、 send によってデータを送信します。この時点で Subject と WebSocketは相性が良いのではないかと考えました。実際この考えを実装した先人がいます。

上記のブログでは Subject と WebSocket を連携する実装までが提示されています。本記事では、連携部分を組み込みつつ、自作のチャットサーバをバックエンドとして、画面を作ってみるところまでやります。画面、Subject、WebSocket の連携イメージは以下のようなものです。

angular-chat

実装をみていきましょう。コアに近いところから順に、WebSocketService、ChatService、ChatComponent です。

WebSocketService - WebSocketを隠蔽してSubjectを提供

websocket.service.ts

import {Subject, Observable, Observer} from 'rxjs/Rx';
import {Injectable} from '@angular/core';

@Injectable()
export class WebSocketService {
  private subject: Subject<MessageEvent>;   // -- ① WebSocket接続時に返す Subject

  connect(url: string): Subject<MessageEvent> {   // -- ②  このメソッドを呼び出して Subject を手に入れます
    if (!this.subject) {
      this.subject = this.create(url);
    }
    return this.subject;
  }

  private create(url: string): Subject<MessageEvent> {
    const ws = new WebSocket(url);  // -- ③ WebSocket オブジェクトを生成します

    const observable = Observable.create((obs: Observer<MessageEvent>) => { // -- ④ Observable オブジェクトを生成します
      ws.onmessage = obs.next.bind(obs);
      ws.onerror = obs.error.bind(obs);
      ws.onclose = obs.complete.bind(obs);

      return ws.close.bind(ws);
    });

    const observer = {    // -- ⑤ Observer オブジェクトを生成します
      next: (data: Object) => {
        if (ws.readyState === WebSocket.OPEN) {
          ws.send(JSON.stringify(data));
        }
      },
    };
    return Subject.create(observer, observable);    // -- ⑥ Subject を生成してconnect
  }
}

画面の部品(Angularの世界で Component といいます)から接続要求をうけたら、Subject のオブジェクトを返すことがこのサービスの役割です。先程、Subject はデータの購読と送出をどちらも行うことができる特徴を持つと述べました。コーディングの観点で言うと「ObservableObserver も実装している」ということです。それぞれ④と⑤で用意しています。

④ Observable オブジェクトの生成では、bind関数を使ってWebSocketオブジェクトに求められる関数を実装しています。Observable の create 時に利用できる Observer オブジェクトの関数を使い、新しい関数を生成してWebSocketの関数の実装として利用します。

コンポーネントはこのサービスを介すことで、WebSocketの世界に足を踏み入れることなく、サーバー側とやりとりできます。今回はサーバーサイドがWebSocketでしたが、ObservavleObserver実装が決まれば、通信プロトコルは問わないはずですし、通信にかぎらず例えばファイルの読み書きなどでも利用できそうです。便利ですね。

ChatService - Subject を利用するためのインターフェース提供

chat.service.ts

import {Injectable} from '@angular/core';
import {Subject} from 'rxjs/Rx';
import {WebSocketService} from './websocket.service';
import {ChatMessage} from './chat/chat.message';

@Injectable()
export class ChatService {

  private messages: Subject<ChatMessage>;

  private chatUrl(roomNumber: string, name: string): string {
    return `ws://localhost:9000/chat/stream/${roomNumber}?user_name=${name}`;   // -- ① WebSocket サーバーの接続先です
  }

  constructor(private ws: WebSocketService) {
  }

  connect(roomNumber: string, name: string): Subject<ChatMessage> { // -- ② チャットの接続。 WebSocketService の connect を呼び出し、 Subject を返します。
    return this.messages = <Subject<ChatMessage>>this.ws
      .connect(this.chatUrl(roomNumber, name))
      .map((response: MessageEvent): ChatMessage => {
        const data = JSON.parse(response.data) as ChatMessage;
        return data;
      });
  }

  send(name: string, message: string): void { // -- ③ メッセージの送信要求をがあったときは、WebSocketService の `next` メソッドを呼んでいるだけです
    this.messages.next(this.createMessage(name, message));
  }

  private createMessage(name: string, message: string): ChatMessage {
    return new ChatMessage(name, message, false);
  }
}

このサービスは、Subject が扱うメッセージのデータ型を、チャットの世界にふさわしいものに変換することが役割です。

ChatComponent

コンポーネントであり、画面の部品です。チャットの画面に使います。

chat.component.ts

import {Component, Input, OnInit, ViewEncapsulation} from '@angular/core';
import {ActivatedRoute, Params} from '@angular/router';
import {ChatService} from '../chat.service';

import {Md5} from 'ts-md5/dist/md5';

@Component({
  selector: 'app-chat',
  templateUrl: './chat.component.html',
  styleUrls: ['./chat.component.css'],
  providers: [ChatService],
  encapsulation: ViewEncapsulation.None
})
export class ChatComponent implements OnInit {

  roomNumber: string;
  name: string;

  // for html
  @Input() messages: ChatModel[] = new Array(); // -- ① htmlで利用するオブジェクトです

  constructor(
    private chatService: ChatService,
    private route: ActivatedRoute
  ) {
  }

  send(message: string): void {
    this.chatService.send(
      this.name, message
    );
  }

  ngOnInit(): void { // -- ② 初期化時に呼び出されます
    this.route.params.forEach((params: Params) => {
      this.roomNumber = params['roomNumber'];
    });

    this.route.queryParams.forEach((params: Params) => {
      this.name = params['name'];
    });

    this.chatService.connect(this.roomNumber, this.name).subscribe(msg => {

      const isMe = msg.userName === this.name;

      this.messages.push(new ChatModel(
        msg.userName,
        msg.text,
        msg.systemFlag,
        {
          me: isMe,
          someone: !isMe
        },
        this.color6(msg.userName)
      ));
    });
  }

  private color6(key: string): string {
    const hash6 = Md5.hashStr(encodeURIComponent(key)).toString().slice(6, 12);
    const rgb = parseInt(hash6, 16) % 1000000;
    return ('000000' + rgb).slice(-6);
  }
}

class ChatModel {

  userName: string;
  text: string;
  systemFlag: boolean;
  speaker: {};
  faceColor: string;

  constructor(userName: string, text: string, systemFlag: boolean, speaker: {}, faceColor: string) {
    this.userName = userName;
    this.text = text;
    this.systemFlag = systemFlag;
    this.speaker = speaker;
    this.faceColor = faceColor;
  }
}

ngOninit では以下のことをやっています。

  • パスパラメータ、クエリパラメータから部屋番号とユーザ名を抽出します。実際にアクセスされる際のパスはhttp://localhost:4200/rooms/1?name=wadaのような形になります。
  • ChatService の connect を呼び出します。これで手に入るのは Subject です。ここで subscribe して ChatModel の配列に変換します。

あとは、ここで手に入った ChatModel[] を使って画面を描画するだけです。

chat.component.html

<div class="container">
  <div appNgAutoScroll class="chat-box">
    <ul class="list-group">
      <li *ngFor="let msg of messages" class="list-group-item" [ngClass]="msg.speaker">
        <div class="col-xs-2 face"><img
          src="http://placehold.jp/24/{{msg.faceColor}}/FFF/100x50.png?text={{msg.userName}}"></div>
        <div class="col-xs-1"></div>
        <div class="col-xs-9 fukidashi"><span>{{msg.text}}</span></div>
      </li>
    </ul>
  </div>
  <div class="send-box">
    <div class="row">
      <div class="col-10"><input #sendMessage class="form-control" (keydown.meta.enter)="send(sendMessage.value); sendMessage.value='';"></div>
      <div class="col-2">
        <button class="btn btn-default" (click)="send(sendMessage.value); sendMessage.value='';">送信</button>
      </div>
    </div>
  </div>
</div>

注目していただきたいのは<ul><li>*ngFor="let msg of messages" です。messages が chat.component.ts で定義した ChatModel 配列が格納されている変数です。画面でこれを使い、チャットユーザの名前やメッセージ内容を読み出すことができるというわけです。

ここまでで、subscribesend の実装さえ決まれば、Subject を利用しての WebSocket サーバーとのやりとりが可能になることがわかりました。

メッセージやりとり以外の Tips

今回の主旨とは直接関係はありませんが、Angularを使って画面を実装する上で便利だなと思ったことなどをメモします。

チャットメッセージが伸びるに従って自動スクロール

やりとりの内容が増えても、最新のメッセージが表示されるように自動でスクロールして欲しいと思いました。Angularの世界ではディレクティブで実現できます。今回はangular2-auto-scrollを使わせていただきました。ただし、執筆時点でDeprecatedとなっており、現在はngx-auto-scrollがメンテナンスされているようです。本記事のソースコードはangular2-auto-scrollをAngular4でも利用できるよう組み込ませて使わせていただきました。

使い方はとても簡単で、app.module.tsdeclarationsに当該ディレクティブを宣言した上で、使用したいコンポーネントのHTMLにディレクティブ名を宣言するだけです。今回の場合はAppNgAutoScrollDirectiveという名前にしています。

app.module.ts

import {AppNgAutoScrollDirective} from '../lib/ng-auto-scroll';

@NgModule({
  declarations: [
    AppComponent,
    ChatComponent,
    LobbyComponent,
    AppNgAutoScrollDirective
  ],
  imports: [
    BrowserModule,
    ...

chat.component.html

<div class="container">
 <div appNgAutoScroll class="chat-box">
   <ul class="list-group">
   ...

チャットのように、HTMLのコンテンツがどんどん増えていくような場合に、スクロールする手間を省きたいときに使えます。

自分の発言を色付け

冒頭のgifで色がつぶれてわかりにくいですが以下のようなイメージです。

me-background

スタイル自体は、class=me に対して背景色を設定しているだけです。属性を設定するかしないかについてAngularで表現しています。

chat.component.html

<li *ngFor="let msg of messages" class="list-group-item" [ngClass]="msg.speaker">
    ...
</li>

[ngClass]="msg.speaker" を使うことで設定しています。msgは ChatModel でした。その speaker は、objectで、発言者の名前が自分と一致しているかどうかを判定してobjectを作成します。Angularは、objectのkey-valueをみて、valueがtrueだった場合、そのkeyをclassとして設定してくれます。今回のように、「特定の条件が成立したときだけスタイルを設定する」といったシーンで役に立ちます。

chat.component.ts

this.messages.push(new ChatModel(
  msg.userName,
  msg.text,
  msg.systemFlag,
  {
    me: isMe,
    someone: !isMe
  },
  this.color6(msg.userName)
));

結果、発言者が自分である場合、 HTML に class=me が反映されます。

Command + Enter でもメッセージ送信できるようにする

HTMLで、(keydown.meta.enter)というイベント時に発火するchat.component.tsの処理を記載するだけです。Angularが記法を用意してくれていたのでとても簡単でした。jQueryだとそれなりのコードを書いていた記憶があります。

caht.component.html

<div class="col-10"><input #sendMessage class="form-control" (keydown.meta.enter)="send(sendMessage.value); sendMessage.value='';"></div>

おまけ:動かすには

バックエンドのAkkaと、Angularを起動すれば動きます。前もって以下が必要です。

  • Java8
  • SBT
  • node.js
  • npm

akka を動かす

$ git clone git@github.com:cm-wada-yusuke/chatserver-play-websocket-akka-stream.git
$ cd chatserver-play-websocket-akka-stream
$ bin/activator run

Angular を動かす

git clone git@github.com:cm-wada-yusuke/angular-websocket-chat.git
cd angular-websocket-chat
npm install
ng serve

localhost:4200へアクセスすると部屋番号と名前を入力する画面が表示されます。

ソースコード

参考