Skip to content

Instantly share code, notes, and snippets.

@Andrew-Chen-Wang
Last active January 23, 2021 13:57
Show Gist options
  • Save Andrew-Chen-Wang/d7d9f1f118a150185caef1aa5b2da71e to your computer and use it in GitHub Desktop.
Save Andrew-Chen-Wang/d7d9f1f118a150185caef1aa5b2da71e to your computer and use it in GitHub Desktop.
Instructions for Multi-Chat One Websocket connection native Django 3.0, no Django channels

That's right, one websocket connection per user because I'm cheap like that (and it's actually easier for mobile devs when it comes to needing info about several other things plus only handling ONE connection).

In wake of growing expectations, I've just created a Django 3.0 multi-chat option, as well, using just caching and the asgi.py. No Django channels. Anyways, I've implemented multi-chat using ONE WebSocket connection per user. Why? One main reason is that my iOS app's ContainerViewController which is like Android's ViewPager and Android's One-activity-multiple-fragment architecture makes using one web socket connection optimal. 1. I needed to communicate several other things between the server and the user by using custom headers and 2. AWS costs a lot of money, especially with API Gateway or simply using EC2 with data transferring On-Demand... The following assumes you've set up native Django 3.0 like so to work with websockets: https://dev.to/jaydenwindle/adding-websockets-to-your-django-app-with-no-extra-dependencies-2f6h.

Anyways, how did I do it? Again, can't show any code nor say much, so bear with me. I'm now a contributor to DRF SimpleJWT which has an experimental feature that allows you to use a stateless TokenUser instance with Django's cached_property decorator. This decorator has cached a user's pk if you manually create an instance of TokenUser via SimpleJWT's authentication backend methodology. The following steps are a method for making sure there is only one socket connection per user (each websocket_application is essentially ONE websocket connection. Docs say:

"The application is called once per “connection.” The definition of a connection and its lifespan are dictated by the protocol specification in question. For example, with HTTP it is one request, whereas for a WebSocket it is a single WebSocket connection.").

First, the client needs to GET request to an APIView (remember to throttle/rate-limit these!). My AUTH_USER_MODEL has an attribute of BoolField called is_ws_connected. The APIView checks that it's false and inserts a cache into redis with the prefix ws_checker_(USER PK) as the key with expiration of 60 seconds (redis automatically searches for these. For me, I saved the cache keys in a local db instead of my RDS since 1. cookiecutter-django delivers with one with Docker and 2. I've had bad experiences with expiring caches that weren't automatically cleaned up in redis). The view also returns all the chat's autoincrementing pk (save these for later). The value can be anything. On websocket.connect, you websocket.accept after grabbing the user's Authentication Bearer token header (remember, websockets are like HTTP, so they have headers and cookies too! Additionally, if authentication fails, instead of sending websocket.accept, send websocket.close). Use the get_user method's code method and other stuff from SimpleJWT in the authentication.py file to get the user's pk (I don't automatically set the Experimentation feature in SimpleJWT settings. I just took its code for websocket sake. I'm looking into implementing websockets for cookiecutter-django since the upgraded to 3.0 and SimpleJWT as I'm commited to making this public without publishing code and... legal stuff). Then, access the cache and look for that ws_checker_(USER PK) cache key. If it's not found, then send websocket.close; otherwise, invalidate the cache as to not waste memory and send websocket.accept. Let's roll.

Now that you're connected, you will need to use asyncio to receive messages from the opposite user. I've created an asyncio loop done with run_forever that checks for specific cache keys to know if the opposite user sent a message (remember, this is for private chats but can be made for group chats as well. Let your imagination grow. Not too hard naming cache keys wink wink). When sending messages with websocket.send, you want to attach a custom header that says the chat id. The next part is difficult as it opens a future theme: async database calling which means more threads.

If I recall from the Django dev group on Google, the creator of psycopg family will be creating an async safe or thread safe psycopg3 soon! In the meantime, we have to use Django channel's database_sync_to_async method (they use asgiref which comes packaged with Django 3.0) to do the following when verifying the chat id is correct (i.e. whether or not the user belongs in this private chat) and saving messages. Unfortunately, when writing this, Celery 5.0 hasn't come out yet, so calling celery tasks asynchronously won't be possible, so we have to use the aforementioned method.

Anyways, I first want to insert the text into the database and then send the text to the other user via caches so that that user's run_forever event loop was activated since that user would be connected via websocket to the server. I have a model with user 1 and user 2 as FKs for each chat, so I created an async function that will first get the instance from the aforementioned model using the chat id header to make sure the user is actually in that chat THEN insert the text to the database in a separate model e.g. MessageThread for chats and Message for actually text (since I only have 1-1 messaging, I just have a BooleanField attribute to represent User 1 and 2). Make sure you do await when calling that function. Once that's done, insert into the cache a certain cache key that you need to make up (I had other needs, so can't share what I did). That aforementioned forever event loop is looking for cache key prefixes (not entire cache key names. Prefixes is key here!). There is a little bit more magic going on such as counting the number of messages within that application and yada yada to help you organize these chat messages. That's for you to work on though.

Oh, I forgot: the event loop function has a parameter send. It's the exact same "send" as the async def websocket_application send. When you first create your loop, pass your send instance to that loop. Thus, when your loop catches an incoming message via the cache, you can use send to actually send the message. For the initial sender, you simply send back "Success" or status 200 afterwards.

That's it! Good luck coming up with cache key names. I apologize for 1. no code and 2. somewhat confusing sentences. I wanted to share my entire journey learning this without getting sued, that's why there are so many links and side notes. Again, the main reason I wanted to share this was because 1. I never learned Django channels and thought Django 3.0 was good enough, 2. my implementation would not have changed whatsoever if I had used Django channels since I was using one websocket per user for multiple chats, 3. I just like learning, and 4. AWS is stupid expensive so I could only limit myself to one connection per user. Have fun learning too!

If you need multi chat, when I said I had a MessageThread model with two FKs, what you can do instead, if using PostgreSQL, is to use JSONField with user's ids. Then, when it comes to knowing who sent what in the Message model, replace BooleanField with FK. It costs more data wise, but not that much.

Additionally, I use Daphne instead of uvicorn since I believe only Daphne does ping-interval checks by itself, according to @andrewgodwin (note: if uvicorn does this, please let me know!), and the server will send("websocket.disconnect") if the user truly disconnected. Make sure your client does automatic pongs when the server pings the client! When I developed my client's iOS chat, I used Starscream which also automatically does ping pongs since these ping intervals are set to very short amount of time (e.g. 10-20 seconds). Make sure your mobile libraries are doing that!

@Andrew-Chen-Wang
Copy link
Author

Andrew-Chen-Wang commented Apr 27, 2020

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment