Slide 1

Slide 1 text

HACKING DJANGO CHANNELS FOR FUN AND PROFIT DJANGOCON EUROPE, JUNE 2021 CALVIN HENDRYX-PARKER

Slide 2

Slide 2 text

BACKSTORY » The web was mostly synchronous up until a few years ago » Async is becoming more a part of Django with each 3.x release

Slide 3

Slide 3 text

REQUESTS AND RESPONSES CommonMiddleware SessionMiddleware CsrfViewMiddleware AuthenticationMiddleware MessageMiddleware HttpRequest HttpResponse process_request process_view process_template_response process_response process_exception view function

Slide 4

Slide 4 text

Django Request Response Loop See https://www.youtube.com/watch?v=RLo9RJhbOrQ

Slide 5

Slide 5 text

WSGI VS ASGI LET’S MAKE IT WORK BI-DIRECTIONAL! Ok… so just swap wsgi.py with asgi.py? See See more on ASGI here: https://arunrocks.com/a-guide-to-asgi-in-django-30-and-its- performance/ https://youtu.be/uRcnaI8Hnzg

Slide 6

Slide 6 text

YOU NEED TO KNOW THAT A WEBSOCKET IS… » Let’s see it in action!

Slide 7

Slide 7 text

ENTER CHANNELS » Consumers 👈 What we think when we talk about Channels and WebSockets » Channel Layers 👈 How we talk to and between our code » Background Workers 👈 Allows for background tasks like Celery

Slide 8

Slide 8 text

CONSUMER EXAMPLE from channels.generic.websocket import JsonWebsocketConsumer class MyConsumer(JsonWebsocketConsumer): def connect(self): self.accept() self.send_json({"connected": "true"}) def disconnect(self, close_code): pass def receive_json(self, content, **kwargs)): type_code = content["type"] data = content["message"] if type_code == "greeting": if (name := data.get("name")): self.send_json({ "type": "reply_to_greeting", "message": f"hello there, {name}!" })

Slide 9

Slide 9 text

No content

Slide 10

Slide 10 text

ASGI ROUTING application = SentryAsgiMiddleware( ProtocolTypeRouter( { # (http->django views is added by default) "websocket": AuthMiddlewareStack( URLRouter(loudswarm.chat.routing.websocket_urlpatterns) ), "channel": ChannelNameRouter( { "discord_send": DiscordSender, } ), }, ) )

Slide 11

Slide 11 text

LET’S TALK ABOUT BACKGROUND WORKERS THAT LAST BIT IS WHERE WE WILL FOCUS

Slide 12

Slide 12 text

WHAT IF WE WANT THE REVERSE?

Slide 13

Slide 13 text

A WORKER IN CHANNELS? What can you do with it?

Slide 14

Slide 14 text

LISTEN FOR MESSAGES ON THE CHANNEL LAYER AND THEN DO SOME WORK. » Fast » Easy » Lightweight Beware: at-most-once operation

Slide 15

Slide 15 text

REAL WORLD USE CASE DISCORD CHAT BOT BUILT INSIDE OF DJANGO

Slide 16

Slide 16 text

LET’S HACK OUR OWN WORKER We want something that Channels can do, but doesn’t out of the box

Slide 17

Slide 17 text

We will do like the Channels runworker and make our own from the asgiref.server.StatelessServer # Only the one method is different from the default worker async def handle(self): """ Listens on all the provided channels and handles the messages. """ # For each channel, launch its own listening coroutine lackeys = [] for channel in self.channels: lackeys.append(asyncio.ensure_future(self.listener(channel))) # Add coroutine for outgoing websocket connection to Discord API lackeys.append(asyncio.ensure_future(self.outgoing_connection())) # Most of our time is spent here, waiting until all the lackeys exit await asyncio.wait(lackeys) # See if any of the listeners had an error (e.g. channel layer error) [lackey.result() for lackey in lackeys]

Slide 18

Slide 18 text

WHY?

Slide 19

Slide 19 text

TWO EXAMPLES We receive messages from Discord and we send them to our clients class ChatClient(discord.Client): async def on_message(self, message): data = { "channel": str(message.channel.id), "event_ts": str(message.created_at.timestamp()), "text": message.content, "ts": str(message.created_at.timestamp()), "user": message.author.nick, "event_id": str(message.id), "event_time": int(message.created_at.timestamp()), "team_id": str(message.guild.id), } await make_webhook_transaction(data)

Slide 20

Slide 20 text

We generate a notification on a scheduled celery task and we want to send it to Discord so we write a consumer that listens for our message from discordworker import discord_client class DiscordSender(AsyncConsumer): async def send_to_discord(self, message): text_msg = message["message"] chat_chan = int(message["discord_channel"]) channel = discord_client.client.get_channel(chat_chan) await channel.send(text_msg) # this is Discord's "channel", not ours

Slide 21

Slide 21 text

and we send it like this await channel_layer.send( "discord_send", # name from ChannelNameRouter config dict( type="send_to_discord", # maps to method name on consumer discord_channel=self.chat_chan, message=message, ), )

Slide 22

Slide 22 text

Use this for any old long running task…

Slide 23

Slide 23 text

WISH LIST » Get this added to Channels codebase » Show examples of long running and single shot coroutines » Add options for two classifications of coroutines » Ones that start right away » Optionally ones the run after those stop

Slide 24

Slide 24 text

TIPS AND TRICKS (AKA NOT IN THE DOCS) » Channel Layer Capacity defaults to 100 If you push more to a channel group, they drop silently

Slide 25

Slide 25 text

SO LONG AND THANKS FOR ALL THE FISH…