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

リポジトリのコードが全てではありますが、一応メモ程度に内容を残しておきます。
とりあえず試す場合
リポジトリをクローンしたら、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

全体をカンバンコンポーネント(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.subscribeでmutationを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自体は日本語のナレッジが少ないので以前の記事が多少参考になるかもしれませんのでよろしければご覧ください。