ChannelsとVue.jsの練習がてらカンバンを実装してみました。同内容は自身のはてブにも投稿しましたが、Channels自体の情報が少ないと思いますのでQiitaにも共有の意味で投稿します。
結構、カンバンの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
構成について
以下を見ればわかるように、このリポジトリでは複数のコンテナを起動させます。
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でのコンポーネントについて
以下の部分にコードがあります。
全体をカンバンコンポーネント(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自体は日本語のナレッジが少ないのでブログの以前の記事が多少参考になるかもしれませんのでよろしければご覧ください。
また、WebSocket + vuexの実装が本当に不明です。こっちのほうがいいってアドバイスいただけるととても喜びます。

