Designing and Engineering "遊び心"駆動開発

Playful IT
2/13 7:03

AWS AppSync Chat Starterのメッセージ表示部分を調査する

前回まで

ChatQLでApollo ClientのOptimistic Responseを使っている部分をちょっとだけ変更して、どのような動作をしているかを確認した。

メッセージ本文を描画しているコードを探す

前回のようにChromeのデベロッパーツールを使ってコードを特定していく。 card-bodyとかchat-messageというクラスがついた要素を探していけば見つけられそう。

AWS AppSync Chat Starterのメッセージ表示部分を調査する

src/app/chat-app/chat-message/chat-message.component.htmlに該当する箇所を発見した。



<div class="card w-75 mb-2 chat-message"
[class.float-right]="fromMe"
[class.border-primary]="fromMe"
[class.border-success]="!fromMe">
<div class="card-body p-2 border rounded">
  <div class='clearfix'>
    <h6 class="card-title mb-1 float-left"
    [class.text-primary]="fromMe"
    [class.text-success]="!fromMe">
    {{user?.username}}
  </h6>
  <div class='float-right'>
    <small class="card-subtitle mb-0 text-muted">{{createdAt | momentAgo }}</small>
    &nbsp;
    <i class="ion-checkmark" [class.text-muted]="!message.isSent" [class.text-info]="message.isSent" data-pack="default" data-tags="talk"></i>
  </div>
</div>
  <p class="card-text mb-0">{{message.content}}</p>
</div>
</div>



import { Component, Input, Output, EventEmitter, AfterViewInit, OnInit, OnChanges, SimpleChanges } from '@angular/core';
import { AppsyncService } from '../appsync.service';
import Message from '../types/message';
import readUserFragment from '../graphql/queries/readUserFragment';
import User from '../types/user';

const USER_ID_PREFIX = 'User:';

@Component({
  selector: 'app-chat-message',
  templateUrl: './chat-message.component.html',
  styleUrls: ['./chat-message.component.css']
})
export class ChatMessageComponent implements AfterViewInit, OnInit, OnChanges {

  @Input() message: Message;
  @Input() fromMe: boolean;
  @Input() isLast = false;
  @Input() isFirst = false;
  @Output() added: EventEmitter<Message> = new EventEmitter();

  user: User;
  createdAt;

  constructor(private appsync: AppsyncService) {}

  ngOnInit() {
    this.appsync.hc().then(client => {
      this.user = client.readFragment({
        id: USER_ID_PREFIX + this.message.sender,
        fragment: readUserFragment
      });
    });
  }

  ngOnChanges(changes: SimpleChanges) {
    for (let propName in changes) {
      if (propName === 'message') {
        const chng = changes[propName];
        this.createdAt = chng.currentValue.createdAt.split('_')[0];
      }
    }
  }

  ngAfterViewInit() {
    if (!this.isFirst && !this.isLast) { return; }
    console.log('message: ngAfterViewChecked: ', this.message.id, this.isFirst, this.isLast);
    this.added.emit(this.message);
  }
}

このChatMessageComponentでは、基本的に@Inputデコレーターにて入力値を親のコンポーネントから受け取るPresentational Componentみたい。基本的に、と書いたのは、ユーザーの情報はまたAppsyncServiceをDIしてそこから手に入れているため。

このようなケースは確かに実装に迷いそうだが、出来るだけContainer ComponentとPresentational Componentに分けたほうが良いのだろうか?

ChatMessageComponentを使っているコンポーネントを探す

ChatMessageComponentのセレクターはapp-chat-messageなので、プロジェクト全体で検索してみた。利用箇所は1箇所のみで、ChatMessageViewComponentというコンポーネントだった。



<div class="bg rounded p-2 border border-dark rounded">
  <div class="p-0 bg">
    <span class="d-block font-weight-bold bg-dark text-white text-right rounded p-2 mb-2">
      <i class="ion-chatbubbles" data-pack="default" data-tags="talk"></i> {{conversation?.name}}
    </span>
    <div #scrollMe class="chat" [appInfscroll]="loadMoreMessages" [completedFetching]="completedFetching">
      <app-chat-message *ngFor="let message of messages; last as isLast; first as isFirst"
      [message]="message"
      [fromMe]="fromMe(message)"
      [isLast]="isLast"
      [isFirst]="isFirst"
      (added)="messageAdded(isFirst, $event)"
      ></app-chat-message>
    </div>
  </div>
</div>

変数messagesを読み込んでいる部分にApollo Clientからデータを引き出す実装があるはず。



const options = {
  query: getConversationMessages,
  fetchPolicy: fetchPolicy,
  variables: {
    conversationId: this._conversation.id,
    first: constants.messageFirst
  }
};

const observable: ObservableQuery<MessagesQuery> = client.watchQuery(options);

observable.subscribe(({data}) => {
  console.log('chat-message-view: subscribe', data);
  if (!data) { return console.log('getConversationMessages - no data'); }
  const newMessages = data.allMessageConnection.messages;
  this.messages = [...newMessages].reverse();
  this.nextToken = data.allMessageConnection.nextToken;
  console.log('chat-message-view: nextToken is now', this.nextToken ? 'set' : 'null');
});

this.subscription = observable.subscribeToMore({
  document: subscribeToNewMessages,
  variables: { 'conversationId': this._conversation.id },
  updateQuery: (prev: MessagesQuery, {subscriptionData: {data: {subscribeToNewMessage: message }}}) => {
    console.log('subscribeToMore - updateQuery:', message);
    return unshiftMessage(prev, message);
  }
});

client.watchQuery()で変更を監視できるっぽい。Optimistic Responseの結果とサーバーからのレスポンスはどちらもここに流れてくるのだろうか。

下の方では、this.subscriptionに何かを代入している。この変数は、このクラスの始まりの方で以下のような型で定義されている。



subscription: () => void;

この定義から関数が入ることがわかる。なぜsubscribesubscribeToMoreが皆目分からないが、もしかしたらどちらかはWebSocketにてサーバー側からプッシュされた情報をハンドリングするものかもしれない。

購読しているっぽい所のログを見る

このような疑問を持つことを想定してか(?)、いい感じにconsole.logが仕込んである。これをデベロッパーツールで注視する。

メッセージを送信したブラウザのタブでは以下のようなログがでた。observable.subscribeobservable.subscribeToMoreのどちらも発火している。

AWS AppSync Chat Starterのメッセージ表示部分を調査する

別画面を開いてメッセージを送信すると、以下のようなログが出た。

AWS AppSync Chat Starterのメッセージ表示部分を調査する

あれ、、、

試しに以下の部分をコメントアウトして他の画面からメッセージを送ってみた。



this.subscription = observable.subscribeToMore({
  document: subscribeToNewMessages,
  variables: { 'conversationId': this._conversation.id },
  updateQuery: (prev: MessagesQuery, {subscriptionData: {data: {subscribeToNewMessage: message }}}) => {
    console.log('subscribeToMore - updateQuery:', message);
    return unshiftMessage(prev, message);
  }
});

すると、メッセージを送った画面以外の画面では、自動的に新着メッセージが届かなかった。

やはり、こちらはWebSocketプロトコルを利用してサーバーから送られてくる情報を購読してローカルのキャッシュに書き込む処理を行なっているらしい。

以前気になった、オフライン時に他の画面から送信されたメッセージがオンラインに復帰しても同期されないのはここらへんを改修することでなんとかできるかもしれない。

オンラインに復帰したタイミングを検知して、そのタイミングでデータを読み込む処理を書く必要があるのか、Apollo Clientでやってくれるのかが現時点では不明。(やってくれるなら前述の問題は発生しないはずだが...)

仮にSubscriptionがGraphQLサーバーの方でキューイングされていて、クライアントがオンラインになったら順次プッシュするような形だったとすると、再接続したタイミングでクライアントの負荷が爆上がりする気がする。

Subscriptionはあくまでアプリケーションがアクティブな状態でのデータ更新の検知として使って、基本的なデータの同期はQueryでフェッチする形が良いのかもしれない。

まとめ

Apollo Clientからclient.watchQueryでデータを引き出してビューに表示する部分のコードを追ってみた。リモートの変更はwatchQueryメソッドの戻り値に対してsubscribeToMoreメソッドを呼ぶことで購読できる(気がする)。

次回はこのあたりをしっかりApollo Clientのドキュメントを見て裏を取りたい。

なんとなく全体を通して分からないこと

  • new AWSAppSyncClient()で生成したインスタンスに対して、hydratedメソッドを呼んでいるがどういう役割があるのか
  • readQuerywatchQuerycache-and-networkのような文字列をfetchPolicyとして指定しているが、指定方法によってどのような違いがあるのか
関連記事
プロフィール