-
Notifications
You must be signed in to change notification settings - Fork 20
Django Channels 2 Design Docs
This document outlines the new design for our implementation of Django Channels 2, it's divided into some sections explaining each part on its own.
- Everything runs in a single process, so there's less pieces to deploy (no need for separate
runworker
) and better latency for responses, also, we don't need to spamself.setup_session()
all over the consumer - Easier handling of sockets in general by providing helpful methods for presence management
- A better testing framework and utilities
- Make the code simpler and clearer
- Completely separate code between Channels features and normal Django's views we have in our codebase (No more
@socket.route('<route>)
in our views)
We are aiming for a typed implementation, these are the types we are going to use
NoneType = type(None)
JsonValue = Union[str, int, float, bool, NoneType]
SocketMessage = Dict[str, JsonValue]
We specify the channels routing via ASGI_APPLICATION
and now we use channels_redis
instead of asgi_redis
ASGI_APPLICATION = 'oddslingers.routing.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [(REDIS_HOST, REDIS_PORT)]
}
}
}
We add the authenticated user and the session to the consumer's scope using AuthMiddlewareStack
application = ProtocolTypeRouter({
'websocket': AuthMiddlewareStack(
URLRouter([
path('ws/table/<str:short_id>/', PokerTableConsumer),
path('ws/tournament/<str:short_id>/', TournamentSummaryConsumer),
...
])
)
})
The ws/
prefix in each path is a good practice to avoid clashes when we use different protocols using the same server to handle them, we can get rid of it if we want.
Thanks to Channel Layers implementation, we can totally get rid of our custom SocketRouter
inside of sockets/router.py
All consumers must inherit from this base in order to have composable consumers
class BaseConsumer(JsonWebsocketConsumer):
supported_messages: List[str] = [
TIME_SYNC_TYPE
]
def connect(self):
# Handle all socket stuff like creating or updating, also authentication checks, etc
def disconnect(self, close_code):
# Delete socket, do presence management related stuff
def receive_json(self, content: SocketMessage):
# This should have proper error handling btw, it's just
# to be simple in docs
msg_type = content.get('type', None)
if msg_type not in self.supported_messages:
# Raise message not supported
handler_method = self.get_handler(msg_type)
handler_method(content)
def get_handler(self, msg_type: str):
method = getattr(self, msg_type, None)
assert method, "MESSAGE"
assert callable(method), "MESSAGE"
return method
def time_sync(self, content: SocketMessage):
self.send_json(TIME_SYNC_TYPE)
With this, we can create consumers per each functional aspect we want to implement, for example:
class GamestateConsumer(BaseConsumer):
supported_messages = [
*BaseConsumer.supported_messages,
'GET_GAMESTATE'
]
... methods to get necessary objects
def get_gamestate(self, content: SocketMessage):
# process gamestate and send gamestate to individual socket
class HistoryConsumer(BaseConsumer):
supported_messages = [
*BaseConsumer.supported_messages,
'GET_HANDHISTORY',
'GET_PLAYER_WINNINGS'
]
def get_handhistory(self, content: SocketMessage):
pass
def get_player_winnings(self, content: SocketMessage):
pass
# Let's suppose GamestateConsumer and HistoryConsumer are all the consumers we need to have a fully workable poker table, then we can do:
class PokerTableConsumer(GamestateConsumer, HistoryConsumer):
# Custom code to complement the consumer
Now we can access the user from the middleware with self.scope['user']
rather than self.message.user
this is auto-provided by using AuthMiddlewareStack
in routing.
We also don't need to guess the user from the http connection header with http_user_and_session
anymore
Same as with User Authentication we can access the session via self.scope['session']
and then store it to our Session
model as usual and relate it to our Socket
Channels 2 provides two methods to help with this: connect
and disconnect
We can use them to do custom actions as printing to the console or doing updates to the Socket
model, but in general, they will handle the general process and make life easy for us.
Extra work will be needed to deal with zombie sockets, we can think about some background task or something like this.
We will have just a plain model to store sockets info, for individual dispatching of events, we're going to use the consumer's send_json
method. Notice that we are not going to use an active
field since we are just going to delete inactive sockets from the database, that way we will have a cleaner and simpler presence management
class Socket(BaseModel):
session
channel_name
path
created_at
last_ping
user_ip
However, to broadcast messages to groups of sockets, we are going to use a simpler version of SocketQuerySet
:
class SocketQuerySet(models.QuerySet):
def send_action(self, content: SocketMessage):
# We can use here the channel layer to send messages outside the consumers
channel_layer = get_channel_layer()
# the_qs would represent any filtered queryset for the group we want to
# send the messages
for socket in self.the_qs:
# We need to use the async_to_sync converter because channel_layer
# is async-implemented
async_to_sync(channel_layer.send)(
socket.channel_name,
content
)
Channels 2 provides us with a nice broadcasting system to propagate message between different sockets at the same time, but, since we are already storing sockets in the Socket
model, we are not going to use built-in groups, instead, we are just going to still use the megaphone
style to broadcast over a SocketQuerySet
using the path
field and sessions.
Honestly this is better and simpler for us since we don't have to add another layer of complexity to the code, just the way it is right now
Coming soon :D
Coming soon too, wait seated :D