DjangoとVueでカンバンアプリケーションを作る(11)

※はじめからは[こちら](https://qiita.com/denzow/items/046f3c8b9bd8d3378eb4#_reference-7b3e4a58510793f8a9c7)

前回まででカードの並び替えはできるようになりましたが、一瞬カードが戻ってしまう状況だったのでそのあたりを改善していきます。

### 一瞬もどる原因

前回実装したVuex側のコードで関係するのはこの部分です。

```javascript
const actions = {
  updateCardOrder({ commit, getters }, { pipeLineId, cardList }) {
    console.log(pipeLineId, cardList);
    const socket = getters.getSocket;
    socket.sendObj({
      type: 'update_card_order',
      pipeLineId,
      cardIdList: cardList.map(x => x.cardId),
    });
  },
};

const mutations = {
  setBoardData(state, { boardData }) {
    state.boardData = camelcaseKeys(boardData, { deep: true });
  },
};
```


カードの並び順の更新は、以下の流れになります。

1. Componentから`updateCardOrder`(action)を呼び出し
1. Websocket経由で情報の更新をサーバにリクエストし
1. サーバ側がWebsocketで新しいカードの並び順(正確にばボード全体のデータ)を返送し
1. `setBoardData`(mutation)が実行される

Component側で、D&Dが完了するのは1.ですが、新しい並び順になるのは4.まで完了した時点です。そのため、その間はD&Dが完了しても、その前のデータがレンダリングされてしまうのでチラツキが発生します。Websocketではサーバが次のメッセージを戻すまで待つといったことができないので、まっとうには避けづらいです。

対策としては、おそらく処理は完了するだろうという楽観的な視点にたって処理を実行したクライアントではサーバのメッセージを待たずにデータを更新してしまうことが有効です。

#### Storeの変更

まずは、Action/Mutationを変更します。

```diff:application/vuejs/src/store/pages/board.js
@@ -29,10 +29,15 @@ const actions = {
       pipeLineId,
       cardIdList: cardList.map(x => x.cardId),
     });
+    commit('updateCardOrder', { pipeLineId, cardList });
   },
 };

 const mutations = {
+  updateCardOrder(state, { pipeLineId, cardList }) { // あるPipeLine内の並びだけ更新する
+    const targetPipeLine = state.boardData.pipeLineList.find(pipeLine => pipeLine.pipeLineId === pipeLineId);
+    targetPipeLine.cardList = cardList;
+  },
   setBoardData(state, { boardData }) {
     state.boardData = camelcaseKeys(boardData, { deep: true });
   },
```

あるPipeLineのカードを更新する`updateCardOrder`(mutation)を追加し、`updateCardOrder`(action)内でサーバにリクエスした直後に`commit('updateCardOrder', { pipeLineId, cardList })`を呼び出し、サーバの返答を待たずに自身のStore内のデータだけは更新するようにします。

これでちらつきはなくなりました。

[![Image from Gyazo](https://i.gyazo.com/a280cc2bb04a85f2850f069f71f3a004.gif)](https://gyazo.com/a280cc2bb04a85f2850f069f71f3a004)

### ブラウザ間のデータ同期

現在のボードの並び替えはブラウザ間で同期されていません。カンバンといえば、他のブラウザでの並び替えが直ちに反映されるのが特徴の一つなので、そこを実装していきます。

#### 基本的な話

Channelsでブラウザをまたがってメッセージの送受信をするには`ChannelLayer`というコンポーネントを使用します。Websocketの接続ごとに生成されるConsumerインスタンスは`ChannelLayer`を通じて相互にメッセージ受信ができるようになります。

#### 設定の変更

ChannelLayerを有効化にするには、まず`CHANNEL_LAYERS`をsettingsに追加します。

```diff:application/settings/base.py
@@ -139,3 +139,13 @@ LOGOUT_REDIRECT_URL = '/'

 # ASGIの起点を指定
 ASGI_APPLICATION = 'views.routing.application'
+
+# バックエンドをRedisにする
+CHANNEL_LAYERS = {
+    'default': {
+        'BACKEND': 'channels_redis.core.RedisChannelLayer',
+        'CONFIG': {
+            "hosts": [('redis', 6379)],
+        },
+    },
+}
```

バックエンドはRedisを指定しています。今回使用しているテンプレートにはRedisサーバのコンテナがすでに組み込まれているので、`redis:6379`をURLと指定すれば使えます。


#### ConsumerにChannelLayerの初期化を追加

Consumerが初期化されるタイミングで、ConsumerをChannelLayerに追加します。ChannelLayerは言うなれば、チャットにおけるルームのような概念で、どのルームに所属するかを指定して初期化します。

```diff:application/views/ws/kanban_consumer.py
@@ -1,3 +1,4 @@
+from asgiref.sync import async_to_sync
 from channels.generic.websocket import JsonWebsocketConsumer

 from modules.kanban import service as kanban_sv
@@ -16,6 +17,7 @@ class KanbanConsumer(JsonWebsocketConsumer):
         self.action_map = {
             'update_card_order': self.update_card_order
         }
+        self.room_group_name = None

     def connect(self):
         # 認証チェック
@@ -28,6 +30,14 @@ class KanbanConsumer(JsonWebsocketConsumer):
         # 接続を受け入れる
         self.accept()

+        # channel layerの初期化
+        self.room_group_name = 'board_id_{}'.format(self.board_id)
+        # ボード毎のグループに参加
+        async_to_sync(self.channel_layer.group_add)(
+            self.room_group_name,
+            self.channel_name
+        )
+
         self.send_board_data()
```

今回は、URLに含まれている`board_id`をルーム名として使って`group_add`を行っています。なお、ChannelLayer関連の処理は非同期になっているので、同期Consumer内で使う場合は`async_to_sync`デコレータを使って同期処理に変換しないといけない点に注意します。

#### ブロードキャスト処理の実装

今は、ConsumerとClientが1:1でメッセージをやり取りする実装になっていました。

```python:application/views/ws/kanban_consumer.py
    def send_board_data(self):
        board_data = kanban_sv.get_board_data_by_board_id(self.board_id)
        self.send_json({
            'boardData': board_data,
            'mutation': 'setBoardData',
            'namespace': self.namespace,
        })

    def update_card_order(self, content):
        """
        ボード内のカードの並び順を更新する
        {
            'type': 'update_card_order',
            'pipeLineId': 1,
            'cardIdList': [3, 1]
        }
        :return:
        """
        pipe_line_id = content['pipeLineId']
        card_id_list = content['cardIdList']
        kanban_sv.update_card_order(pipe_line_id, card_id_list)
        self.send_board_data()
```

`update_card_order`の完了時に呼び出される`send_board_data`はConsumerと紐づくClientにだけ新しいボードのデータを返送する実装です。これを同じ`room_group_name`に所属しているConsumerすべてに`send_board_data`を呼び出させるようにします。

ChannelLayerを通じてメッセージを送るには`self.channel_layer.group_send`を使用します。また、非同期処理なのでこちらも`sync_to_async`でラップします。

```diff:application/views/ws/kanban_consumer.py
@@ -40,7 +40,7 @@ class KanbanConsumer(JsonWebsocketConsumer):

         self.send_board_data()

-    def send_board_data(self):
+    def send_board_data(self, event=None, *args, **kwargs):
         board_data = kanban_sv.get_board_data_by_board_id(self.board_id)
         self.send_json({
             'boardData': board_data,
@@ -61,7 +61,13 @@ class KanbanConsumer(JsonWebsocketConsumer):
         pipe_line_id = content['pipeLineId']
         card_id_list = content['cardIdList']
         kanban_sv.update_card_order(pipe_line_id, card_id_list)
-        self.send_board_data()
+        # 同じグループ(自身も含む)に`send_board_data`を呼び出すように通知
+        async_to_sync(self.channel_layer.group_send)(
+            self.room_group_name,
+            {
+                'type': 'send_board_data',
+            }
+        )

     def receive_json(self, content, **kwargs):
         """
```

もともと`update_card_order`の最後で`send_board_data `を呼び出していた部分を`self.channel_layer.group_send`に置き換えています。この時、第一引数にはどのグループにメッセージを通知するかを指定するので、ここでは自身と同じ`room_group_name `を指定します。第二引数にはメッセージ内容を指定しますが、大事なのは`type`キーです。ここに文字列で指定したメソッド名が各Consumerで呼び出されるようになります。

そのため、この部分のコードでは、自身と同じ`room_group_name `に所属しているConsumerすべてにメッセージを送信し、受け取ったConsumer(送信元自身のConsumerも含む)が`type`に指定された`send_board_data`が呼び出され、それぞれのConsumerに紐付いたClientに新しいボードデータを戻します。

2つのブラウザで開いてるときを例にざっくりとした処理の流れは以下のようになります。

1. Client1がカード並び替えを実行
2. Consumer1が新しい並びでカードを更新
3. Consumer1が同じGroupに所属するConsumerにメッセージを送信
4. Consumer1,Consumer2がメッセージを受信
5. Consumer1がClient1,Consumer2がClient2に新しい並びでのボードデータを送信
6. Client1,Client2が新しい並びで再レンダリング

実際にこんな感じで複数ブラウザで処理が同期しています。

[![Image from Gyazo](https://i.gyazo.com/851231929bdf60f22284692134872818.gif)](https://gyazo.com/851231929bdf60f22284692134872818)

だいぶ完成に近づいてきました。

### 次回

https://qiita.com/denzow/items/744ce54c7dbda89d8ac1