Django Channels + Vue.js でWebSocketをつかってTrelloみたいなカンバンを作ってみた

ChannelsとVue.jsの練習がてらカンバンを実装してみました。同内容は自身のはてブにも投稿しましたが、Channels自体の情報が少ないと思いますのでQiitaにも共有の意味で投稿します。

![712604e4f8a8a88317919c6ae4850b59.gif](https://qiita-image-store.s3.amazonaws.com/0/44722/f3ea5647-fb7e-1b7a-d5b0-6fd5316f36aa.gif)

https://github.com/denzow/channel-kanban

結構、カンバンのUIの記事はあるのですがサーバ側も含めての情報が少なく、WebSocketで同期も取れるようにしているので多少珍し目かもしれません。

注意
-----------------------

あくまでカンバンのように動く部分だけを実装していますので、本来必要になるであろうログインやユーザ管理等は含んでいません。また**カードのコンテンツの編集**等実装していません。くるくる動かして遊んでください。

とりあえず試す場合
------------------------

リポジトリをクローンしたら、`docker-compose up`するだけで動きます。あとはブラウザから`http://localhost:8000/kanban/1`にアクセスすればカンバンが試せます。なお末尾のIDを変えれば新規カンバンが作成できます。


利用バージョンについて
------------------------

主要なコンポーネントのバージョンは以下です。

* Python 3.6.4
* Django 2.0.3
* DjangoChannels 2.0.2
* Vue.js 2.5.16
* vuedraggable 2.16.0
* vuex 3.0.1


構成について
---------------

以下を見ればわかるように、このリポジトリでは複数のコンテナを起動させます。

https://github.com/denzow/channel-kanban/blob/master/docker-compose.yml

`docker-compose up`をすると以下の状態になります。

```
(dws36) denzownoMacBook-Pro:channel-kanban denzow$ docker ps
CONTAINER ID        IMAGE                         COMMAND                  CREATED             STATUS              PORTS                                     NAMES
c7111712546b        channelkanban_service_nginx   "/start-nginx.sh"        4 hours ago         Up 3 hours          80/tcp, 443/tcp, 0.0.0.0:8000->8000/tcp   channelkanban_service_nginx_1
3b5b755a2de9        channelkanban_service         "/app/docker/service…"   4 hours ago         Up 3 hours          0.0.0.0:3000->3000/tcp                    channelkanban_service_1
be6c661f28c0        channelkanban_websocket       "/app/docker/websock…"   4 hours ago         Up 3 hours          0.0.0.0:4000->3000/tcp                    channelkanban_websocket_1
3273576ecf46        postgres                      "docker-entrypoint.s…"   4 hours ago         Up 3 hours          5432/tcp                                  channelkanban_db_1
79d6e3ff2bad        channelkanban_task_runner     "sh /app/work/docker…"   21 hours ago        Up 3 hours                                                    channelkanban_task_runner_1
5ebecf797326        channelkanban_redis           "docker-entrypoint.s…"   31 hours ago        Up 3 hours          0.0.0.0:6379->6379/tcp                    channelkanban_redis_1
```

|イメージ名|用途|
|-----------------------------|-----------------------------------|
|channelkanban_service_nginx |サービスへのリバースプロキシ用フロントWEBサーバ |
|channelkanban_service       |Djangoサーバ(HTTP) |
|channelkanban_websocket     |Djangoサーバ(WebSocket) |
|postgres                    |DjangoバンクエンドDB用のPostgreSQLサーバ |
|channelkanban_redis         |Django Channelsで使用するRedisサーバ |
|channelkanban_task_runner   |Vue.js等をコンパイルするwebpack用サーバ |


6コンテナが起動して結構仰々しいです。特にDjangoが2コンテナ上がってしまいます。本番用であれば`daphne`をつかって1コンテナだけで済みますし、開発中のホットリロードを実現するには`runserver`でいいのですがそれぞれ以下の問題があります。

* daphne
    - ホットリロードがない
    - HTTP/WSはどちらも対応できる
* runserver(channels有効済)
    - ホットリロードあり
    - HTTP/WSはどちらも対応できる
    - staticfileの配信がおかしい(できない？)

という状況です。そこでHTTPとStaticfileの配信を`channelkanban_service`で行い、WebSocketだけ`channelkanban_websocket`で行っています。WebSocketでのアクセスは`/ws/`をパスに含めるようにしてnginxでリバースプロキシさせました。

Vue.jsでのコンポーネントについて
--------------------------------

以下の部分にコードがあります。

https://github.com/denzow/channel-kanban/tree/master/application/src/vuejs

![components.png](https://qiita-image-store.s3.amazonaws.com/0/44722/cd51f56d-ae00-e3d0-29d3-b76d65a7d663.png)


全体をカンバンコンポーネント(`App.vue`)として、リストに相当する`PipeLine.vue`とそれに所属するカードを`Card.vue`として分けています。構成的には`App.vue` -> `PipeLine.vue` -> `Card.vue`という形です。

一応Vuexを練習がてらつかったのでカンバンのデータはVuexのStoreにいれています。また、`vuedraggable`を使うとそれだけで簡単にカンバンに必要なドラッグアンドドロップの実装ができました。あとは変更時にそのデータを永続化するだけでカンバンが作れます。

### WebSocketについて

複数のブラウザでカンバンの状態をリアルタイムに同期するためWebSocketを使っています。ここの実装がかなり悩んでいて若干苦肉の策なので一番洗練されていないと思います。まず以下はStoreです。

```javascript
import createWebSocketPlugin from './WebSocketPlugin';

const socket = new WebSocket('ws://' + window.location.host + '/ws' + window.location.pathname);
const plugin = createWebSocketPlugin(socket);


const store = {
    state: {
        pipeLineList: [],
    },
    actions: {
        add_pipeline(context, payload){
            console.log('action add_pipeline called', payload);
        },
        add_card(context, payload){
            console.log('action add_card called', payload);
        },
        update(context, payload){
            console.log('action update called', payload);
        }
    },
    mutations: {
        set_data(state, payload){
            console.log('set_data', state, payload);
            this.state.pipeLineList = payload.kanban;
        },
    },
    plugins: [plugin]
};

export default store;
```

`mutation`には全てのデータをまるっと置き換える`set_data`を定義しています。しかし見てわかるようにいずれの`action`からも呼び出されていません。またどの`action`も何も行っていません。

実際に`mutation`の呼び出しをしているのは`plugin`側です。

```javascript
export default function createWebSocketPlugin (socket) {
    return store => {
        // サーバからの返答をもってmutationする
        socket.onmessage = e => {
            store.commit(data.type, data)
        };
        // ActionをPlugin側でHookしてwsに投げる
        store.subscribeAction((action) => {
            switch(action.type){
                case 'update':
                    socket.send(JSON.stringify({
                        type: 'update',
                        payload: action.payload
                    }));
                    break;
                case 'add_card':
                    socket.send(JSON.stringify({
                        type: 'add_card',
                        payload: action.payload
                    }));
                    break;
                case 'add_pipeline':
                    socket.send(JSON.stringify({
                        type: 'add_pipeline',
                        payload: action.payload
                    }));
                    break;
            }
        })
    }
}
```
`store.subscribeAction`を利用して、Storeへの`dispatch`をHookしています。`store.subscribe`で`mutation`をHookしてもいいのですが、状態を確定させるのはWebSocketでサーバと通信できてから似する必要があるため`store.subscribeAction`にしています。ここでそのままサーバにWebSocketを通してメッセージを送信しています。

また、サーバからメッセージが戻された場合に`store.commit(data.type, data)`で`mutation`を呼び出すようにしています。なお、一応`type`ごとに`commit`する実装ですが現状は`type:set_data`で必ず返送してカンバンのすべてのデータを差し替えるようにしています。

consumer側の実装
---------------------

ChannelsでWebSocket経由の処理を定義するのは`consumer`です。本当は同期実装のConsumerのほうが楽だったのですが気がついたら非同期実装でやってたのでそのままになっています。


```python
import json

from channels.generic.websocket import AsyncWebsocketConsumer
from modules.kanban import service as kanban_sv


class KanbanConsumer(AsyncWebsocketConsumer):
    """
    WebSocket通信のハンドラ(非同期実装)
    """
:
    async def connect(self):
        self.kanban_id = self.scope['url_route']['kwargs']['kanban_id']
        self.kanban_name = 'kanban_{}'.format(self.kanban_id)

        # Join room group
        await self.channel_layer.group_add(
            self.kanban_name,
            self.channel_name
        )
        await self.accept()
        # 初期データ
        await self.send(text_data=json.dumps({
            'kanban': kanban_sv.get_whole_json(self.kanban_id),
            'type': 'set_data',
        }))
:
    async def receive(self, text_data=None, bytes_data=None):
        text_data_json = json.loads(text_data)
        message_type = text_data_json['type']
        payload = text_data_json['payload']
        # typeに応じた処理へディスパッチ
        await self.type_map[message_type](payload)
:
    async def updated(self, event):
        payload = event['payload']
        # 一旦全部カンバン更新してしまう
        await self.send(text_data=json.dumps({
            'type': 'set_data',
            'kanban': kanban_sv.get_whole_json(self.kanban_id),
        }))
:
    async def _update(self, payload):
        print('_update', payload)
        kanban_sv.update_kanban(
            pipeline_id=payload['pipeLineId'],
            card_id_list=[x['id'] for x in payload['newCardList']]
        )
        # Send message to room group
        await self.channel_layer.group_send(
            self.kanban_name,
            {
                'type': 'updated',
                'payload': {}
            }
        )

    async def _add_card(self, payload):
        kanban_sv.add_card(
            pipeline_id=payload['pipeLineId'],
            title=payload['title'],
            order=payload['order'],
        )
        # Send message to room group
        await self.channel_layer.group_send(
            self.kanban_name,
            {
                'type': 'updated',
                'payload': {}
            }
        )

    async def _add_pipeline(self, payload):
        kanban_sv.add_pipeline(
            kanban_id=payload['kanbanId'],
            title=payload['title'],
            order=payload['order'],
        )
        # Send message to room group
        await self.channel_layer.group_send(
            self.kanban_name,
            {
                'type': 'updated',
                'payload': {}
            }
        )

```

`_update`はカードをドラッグアンドドロップしたときの処理です。vuedraggable自体がD&Dを行った際に操作されたリストに属する最新のカードの一覧を戻してくれます。あとはそれを元にサーバ側の`Card`モデルのUpdateを行うだけで良いです。

また、`_add_card`や`_add_pipeline`はその名の通りカードやパイプラインを新規追加した際に対応するモデルを生成しているだけです。

いずれの処理が完了した場合でも`self.channel_layer.group_send`で`'type': 'updated'`のメッセージを同じカンバンに紐付いているConsumer全てに送信し、それぞれの`def updated`が呼び出されています。この関数ではKanbanに属するコンポーネント全体のツリーをJSONで戻すようになっていますので、あとは受け取ったVue側で再レンダリングすればカンバンはすべてのクライアントで最新の状態になります。

まとめ
------------------

環境構築には時間がかかりましたが、カンバン自体は比較的容易に作成できました。`vuedraggable`はかなり簡単にカンバンのUIが作成できましたし、WebSocketは`Channels`で簡単でした。ただ、VuexとWebSocketの関係をいい感じにするベストプラクティスがわからなかったので今後は機会を見て詰めていきたいと思います。

なお、Channels自体は日本語のナレッジが少ないので[ブログの以前の記事](http://www.denzow.me/archive/category/Channels)が多少参考になるかもしれませんのでよろしければご覧ください。

また、WebSocket + vuexの実装が本当に不明です。こっちのほうがいいってアドバイスいただけるととても喜びます。