この記事はQiita Django Advent Calendar 2018 12日目です。
DjangoでWebsocketを使う場合、現時点において最も信頼できるのはDjango channelsを使うことでしょう。Channelsであれば、比較的わかりやすくWebsocketの処理を実装することができます。
例えばカンバンアプリケーションを考えたときに、カードの移動やカード名の変更などはすべてWebsocket経由で処理することができますが、それだと少し苦しい場合もあります。このニッチな点について記します。
Django ChannelsでのWebsocketの処理
ざっくりとこの図のようになっています。元スライド
各ブラウザと1:1でConsumerが存在し、Consumerの後ろにChannelLayer(Redis利用)があり、ここを経由で各Consumerが同期します。
悩みどころ
Websocket経由でもできるけど、うーんとなるのが失敗するかもしれない処理と完了通知をしたい処理です。単純なカンバンであれば良いのですが、追加するカードにサーバサイドでバリデーションがかかったり処理が競合しやすく、失敗が予見されるような場合が該当します。
サンプル
今回は、カンバンのリストの名前変更が失敗しうる処理とします。題材とするサンプルアプリケーションはこれです。
Django channels + vue/vuexでの構成になっています。該当の処理は以下の内容です。
リスト名をbefore
からafter
に変更しています。この変更は、同じボードを開いている他のブラウザにもリアルタイムに反映させる必要があります。
初期実装
変更をリアルタイムに他のブラウザに反映させるためには、Websocketの力が必要です。なので、そのままWebsocket経由で名前変更を実装すると以下の流れになります。
- 編集開始のフラグを立てて、入力欄を出す
- 内容を書き換え(
before
->after
) - saveボタンを押下
- 処理中のデザインに切り替える(グレーアウト)
- Websocket経由で新しい名前をConsumerに送信
- 処理完了通知を行い、入力欄を閉じる
コード的にはこんな感じです。
まずは、編集中フラグ(isEditingPipeLineName
)を立てて既存の名前を初期値にいれます。
startPipeLineNameEdit() {
this.isEditingPipeLineName = true;
this.editPipeLineName = this.pipeLine.name;
},
そして、保存時は編集中フラグ(isEditingPipeLineName
)をおろしてWebsocket経由で名前の変更を依頼(renamePipeLine
)して、リネーム処理中フラグ(isWaitingRename
)を立てます
async savePipeLineName() {
this.isEditingPipeLineName = false;
if (this.editPipeLineName === this.pipeLine.name) return;
await this.renamePipeLineByWebsocket({
pipeLineId: this.pipeLine.pipeLineId,
pipeLineName: this.editPipeLineName,
});
// リネーム完了までのフラグ
this.isWaitingRename = true;
},
renamePipeLineByWebsocket
はVuexでこんなActionになっています。socket.sendObj
でrename_pipe_line
というメッセージをConsumerに伝えています。
renamePipeLine({ getters }, { pipeLineId, pipeLineName }) {
const socket = getters.getSocket;
socket.sendObj({
type: 'rename_pipe_line',
pipeLineId,
pipeLineName,
});
},
Consumer側ではデータ更新を行い、各クライアントにデータ再取得を行わせます。
async def rename_pipe_line(self, content):
"""
パイプライン名の変更
"""
pipe_line_id = content['pipeLineId']
pipe_line_name = content['pipeLineName']
await database_sync_to_async(kanban_sv.update_pipe_line)(pipe_line_id, pipe_line_name)
await self.broadcast_board_data()
そして、フロント側ではパイプライン名をWatchすることで処理完了を検知します。
watch: {
pipeLine(newPipeLine, oldPipeLine) {
if (newPipeLine.name !== oldPipeLine.name) {
this.isWaitingRename = false;
}
},
},
これで一応動作は実装できていますが、非常に面倒な形になっています。特に編集中という状態の管理が直感的でなくなっています。
なぜ面倒なのか
たとえばrenamePipeLine
が仮にAjaxで処理されるのであれば
async savePipeLineName() {
this.isEditingPipeLineName = false;
if (this.editPipeLineName === this.pipeLine.name) return;
// リネーム開始
this.isWaitingRename = true;
await this.renamePipeLineByAjax({
pipeLineId: this.pipeLine.pipeLineId,
pipeLineName: this.editPipeLineName,
});
// リネーム完了
this.isWaitingRename = false;
},
といった感じで、isWaitingRename
の管理は簡単ですし、watch
なども使う必要はありません。Ajaxの場合は、処理を投げてから完了までを待機することができますがWebsocketの場合は、処理を依頼するというメッセージングに対して完了を待機することができません。単に投げっぱなしになります。そのため、フラグ管理が煩雑になりやすいです。
どうするのか
ほぼ答えは出ているようなものですが、更新処理をAjaxに切り出すことが望ましいと思います。
こうなっている処理を
renamePipeLine({ getters }, { pipeLineId, pipeLineName }) {
const socket = getters.getSocket;
socket.sendObj({
type: 'rename_pipe_line',
pipeLineId,
pipeLineName,
});
},
こう書き換えます
async renamePipeLine({ getters }, { pipeLineId, pipeLineName }) {
await AjaxClient.renamePipeLine({
boardId,
cardId,
});
// この時点でリネームが完了していることが保証できる。
dispatch('broadcastBoardData');
},
broadcastBoardData({ getters }) {
const socket = getters.getSocket;
socket.sendObj({
type: 'broadcast_board_data',
});
},
リネーム処理をAjax化し、完了後に各クライアントの更新を促すメッセージ(broadcast_board_data
)をクライアントからWebsocket経由で送ります。先程まで、データの更新をbroadCastはConsumer側で行っていましたが、こちらの実装ではクライアント側が主体となってデータ更新とbroadCastを管理するようになっています。
変更前のほうがパフォーマンス的には優れていますが、書き換え後の場合はコンポーネント側が以下のようにフラグ管理をスッキリできるのでいろいろと楽です。
async savePipeLineName() {
this.isEditingPipeLineName = false;
if (this.editPipeLineName === this.pipeLine.name) return;
// リネーム開始
this.isWaitingRename = true;
await this.renamePipeLineByAjax({
pipeLineId: this.pipeLine.pipeLineId,
pipeLineName: this.editPipeLineName,
});
// リネーム完了
this.isWaitingRename = false;
},
他のアプローチ
Ajaxで処理が完了した際に、クライアント側で再度broadcast_board_data
のメッセージを送るのではなく、Ajaxで叩いたエンドポイント内の処理でそのままbroadcast_board_data
相当の処理をしてくれれば、よりクライアント側がすっきりできます。
各Consumer間の同期をとるChannelLayerはConsumer内でしか使えないと誤解されがちですが、Consumer外からでもChannelLayerにアクセスする事が可能です。
Using Outside Of Consumers にあるようにget_channel_layer
を使い、取得されたChannelLayer経由で各Consumerにメッセージを送信することができます。
実際の業務ではいろいろな理由で採用には至りませんでしたが、get_channel_layer
をAPIに組み込むことで、より透過的にWebsocketとAjaxを組み合わせることができます。
まとめ
PyConJPからずっとカンバンとChannelのアウトプットしかしてないですし、今回の記事にいたってはニッチ過ぎてダレ得感もありますが、国内でのChannelsの事例が増えていけばいいなって思ってます。