Skip to content

Django Channels 2 Design Docs

Angel Rey edited this page Dec 23, 2020 · 1 revision

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.

Benefits from the migration

  • 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 spam self.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)

Design

Types

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]

Settings Changes

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)]
        }
    }
}

Routing

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

Consumers

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

User Authentication

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

Sessions

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

Presence Management

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.

Socket Model

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
			)

Groups

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

Testing

Coming soon :D

Frontend Changes

Coming soon too, wait seated :D