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