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

この記事は[Qiita Django Advent Calendar 2018 12日目](https://qiita.com/advent-calendar/2018/django)です。

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

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

### Django ChannelsでのWebsocketの処理

ざっくりとこの図のようになっています。[元スライド](https://speakerdeck.com/denzow/djangotovuedezuo-rukanbanapurikesiyon?slide=85)

![DjangoとVueで作るカンバンアプリケーション より](https://qiita-image-store.s3.amazonaws.com/0/44722/e2df5af8-8a55-9c2f-9dae-42a64aabf5cb.png)

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

### 悩みどころ

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

### サンプル

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

https://github.com/denzow/DjangoDeKanban

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

![](https://i.gyazo.com/4e28ae29ab38bf2f9c69dcf80f0771c1.gif)

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

### 初期実装

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

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

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


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

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

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

```javascript:フロントのコンポーネント
    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.sendObj`で`rename_pipe_line`というメッセージをConsumerに伝えています。

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

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

```python:サーバサイドの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することで処理完了を検知します。

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

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

### なぜ面倒なのか

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

```javascript
    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に切り出すことが望ましいと思います。

こうなっている処理を

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

こう書き換えます

```javascript: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を管理するようになっています。

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

```javascript
    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](https://channels.readthedocs.io/en/latest/topics/channel_layers.html#using-outside-of-consumers) にあるように`get_channel_layer`を使い、取得されたChannelLayer経由で各Consumerにメッセージを送信することができます。

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

### まとめ

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