11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

DjangoAdvent Calendar 2018

Day 12

Django channelsでのリソースの更新の悩みどころ

Posted at

この記事はQiita Django Advent Calendar 2018 12日目です。

DjangoでWebsocketを使う場合、現時点において最も信頼できるのはDjango channelsを使うことでしょう。Channelsであれば、比較的わかりやすくWebsocketの処理を実装することができます。

例えばカンバンアプリケーションを考えたときに、カードの移動やカード名の変更などはすべてWebsocket経由で処理することができますが、それだと少し苦しい場合もあります。このニッチな点について記します。

Django ChannelsでのWebsocketの処理

ざっくりとこの図のようになっています。元スライド

DjangoとVueで作るカンバンアプリケーション より

各ブラウザと1:1でConsumerが存在し、Consumerの後ろにChannelLayer(Redis利用)があり、ここを経由で各Consumerが同期します。

悩みどころ

Websocket経由でもできるけど、うーんとなるのが失敗するかもしれない処理と完了通知をしたい処理です。単純なカンバンであれば良いのですが、追加するカードにサーバサイドでバリデーションがかかったり処理が競合しやすく、失敗が予見されるような場合が該当します。

サンプル

今回は、カンバンのリストの名前変更が失敗しうる処理とします。題材とするサンプルアプリケーションはこれです。

Django channels + vue/vuexでの構成になっています。該当の処理は以下の内容です。

リスト名をbeforeからafterに変更しています。この変更は、同じボードを開いている他のブラウザにもリアルタイムに反映させる必要があります。

初期実装

変更をリアルタイムに他のブラウザに反映させるためには、Websocketの力が必要です。なので、そのままWebsocket経由で名前変更を実装すると以下の流れになります。

  1. 編集開始のフラグを立てて、入力欄を出す
  2. 内容を書き換え(before -> after)
  3. saveボタンを押下
  4. 処理中のデザインに切り替える(グレーアウト)
  5. Websocket経由で新しい名前をConsumerに送信
  6. 処理完了通知を行い、入力欄を閉じる

コード的にはこんな感じです。

まずは、編集中フラグ(isEditingPipeLineName)を立てて既存の名前を初期値にいれます。

フロントのコンポーネント
    startPipeLineNameEdit() {
      this.isEditingPipeLineName = true;
      this.editPipeLineName = this.pipeLine.name;
    },

そして、保存時は編集中フラグ(isEditingPipeLineName)をおろしてWebsocket経由で名前の変更を依頼(renamePipeLine)して、リネーム処理中フラグ(isWaitingRename)を立てます

フロントのコンポーネント
    async savePipeLineName() {
      this.isEditingPipeLineName = false;
      if (this.editPipeLineName === this.pipeLine.name) return;
      await this.renamePipeLineByWebsocket({
        pipeLineId: this.pipeLine.pipeLineId,
        pipeLineName: this.editPipeLineName,
      });
      // リネーム完了までのフラグ
      this.isWaitingRename = true;
    },

renamePipeLineByWebsocketはVuexでこんなActionになっています。socket.sendObjrename_pipe_lineというメッセージをConsumerに伝えています。

vuex
  renamePipeLine({ getters }, { pipeLineId, pipeLineName }) {
    const socket = getters.getSocket;
    socket.sendObj({
      type: 'rename_pipe_line',
      pipeLineId,
      pipeLineName,
    });
  },

Consumer側ではデータ更新を行い、各クライアントにデータ再取得を行わせます。

サーバサイドのConsumer
    async def rename_pipe_line(self, content):
        """
        パイプライン名の変更
        """
        pipe_line_id = content['pipeLineId']
        pipe_line_name = content['pipeLineName']
        await database_sync_to_async(kanban_sv.update_pipe_line)(pipe_line_id, pipe_line_name)
        await self.broadcast_board_data()

そして、フロント側ではパイプライン名をWatchすることで処理完了を検知します。

フロントのコンポーネント
  watch: {
    pipeLine(newPipeLine, oldPipeLine) {
      if (newPipeLine.name !== oldPipeLine.name) {
        this.isWaitingRename = false;
      }
    },
  },

これで一応動作は実装できていますが、非常に面倒な形になっています。特に編集中という状態の管理が直感的でなくなっています。

なぜ面倒なのか

たとえばrenamePipeLineが仮にAjaxで処理されるのであれば

    async savePipeLineName() {
      this.isEditingPipeLineName = false;
      if (this.editPipeLineName === this.pipeLine.name) return;
      // リネーム開始
      this.isWaitingRename = true;
      await this.renamePipeLineByAjax({
        pipeLineId: this.pipeLine.pipeLineId,
        pipeLineName: this.editPipeLineName,
      });
      // リネーム完了
      this.isWaitingRename = false;
    },

といった感じで、isWaitingRenameの管理は簡単ですし、watchなども使う必要はありません。Ajaxの場合は、処理を投げてから完了までを待機することができますがWebsocketの場合は、処理を依頼するというメッセージングに対して完了を待機することができません。単に投げっぱなしになります。そのため、フラグ管理が煩雑になりやすいです。

どうするのか

ほぼ答えは出ているようなものですが、更新処理をAjaxに切り出すことが望ましいと思います。

こうなっている処理を

vuex
  renamePipeLine({ getters }, { pipeLineId, pipeLineName }) {
    const socket = getters.getSocket;
    socket.sendObj({
      type: 'rename_pipe_line',
      pipeLineId,
      pipeLineName,
    });
  },

こう書き換えます

vuex
  async renamePipeLine({ getters }, { pipeLineId, pipeLineName }) {
    await AjaxClient.renamePipeLine({
      boardId,
      cardId,
    });
    // この時点でリネームが完了していることが保証できる。
    dispatch('broadcastBoardData');
  },
  broadcastBoardData({ getters }) {
    const socket = getters.getSocket;
    socket.sendObj({
      type: 'broadcast_board_data',
    });
  },

リネーム処理をAjax化し、完了後に各クライアントの更新を促すメッセージ(broadcast_board_data)をクライアントからWebsocket経由で送ります。先程まで、データの更新をbroadCastはConsumer側で行っていましたが、こちらの実装ではクライアント側が主体となってデータ更新とbroadCastを管理するようになっています。

変更前のほうがパフォーマンス的には優れていますが、書き換え後の場合はコンポーネント側が以下のようにフラグ管理をスッキリできるのでいろいろと楽です。

    async savePipeLineName() {
      this.isEditingPipeLineName = false;
      if (this.editPipeLineName === this.pipeLine.name) return;
      // リネーム開始
      this.isWaitingRename = true;
      await this.renamePipeLineByAjax({
        pipeLineId: this.pipeLine.pipeLineId,
        pipeLineName: this.editPipeLineName,
      });
      // リネーム完了
      this.isWaitingRename = false;
    },

他のアプローチ

Ajaxで処理が完了した際に、クライアント側で再度broadcast_board_dataのメッセージを送るのではなく、Ajaxで叩いたエンドポイント内の処理でそのままbroadcast_board_data相当の処理をしてくれれば、よりクライアント側がすっきりできます。

各Consumer間の同期をとるChannelLayerはConsumer内でしか使えないと誤解されがちですが、Consumer外からでもChannelLayerにアクセスする事が可能です。

Using Outside Of Consumers にあるようにget_channel_layerを使い、取得されたChannelLayer経由で各Consumerにメッセージを送信することができます。

実際の業務ではいろいろな理由で採用には至りませんでしたが、get_channel_layerをAPIに組み込むことで、より透過的にWebsocketとAjaxを組み合わせることができます。

まとめ

PyConJPからずっとカンバンとChannelのアウトプットしかしてないですし、今回の記事にいたってはニッチ過ぎてダレ得感もありますが、国内でのChannelsの事例が増えていけばいいなって思ってます。

11
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?