[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

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

ChannelsとVue.jsの練習がてらカンバンを実装してみました。割りとカンバンのUIの実装記事はあるのですが、サーバ側までセットになったものが少なかったのでがんばりました。

f:id:denzow:20180415223219g:plain

github.com

リポジトリのコードが全てではありますが、一応メモ程度に内容を残しておきます。

とりあえず試す場合

リポジトリをクローンしたら、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
image_name description
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
    • ホットリロードがない
    • HTTTP/WSはどちらも対応できる
  • runserver(channels有効済)
    • ホットリロードあり
    • HTTTP/WSはどちらも対応できる
    • staticfileの配信がおかしい(できない?)

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

ただ単に--nostaticをrunserverにつけ忘れてただけでした汗。時間を見つけて修正します

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

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

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

f:id:denzow:20180415223139p:plain

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

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

WebSocketについて

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

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側です。

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.subscribemutationをHookしてもいいのですが、状態を確定させるのはWebSocketでサーバと通信できてから似する必要があるためstore.subscribeActionにしています。ここでそのままサーバにWebSocketを通してメッセージを送信しています。

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

consumer側の実装

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

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自体は日本語のナレッジが少ないので以前の記事が多少参考になるかもしれませんのでよろしければご覧ください。