diff --git a/.gitignore b/.gitignore index 1c527d5..91b64df 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .tox/ *.swp *.pyc +TODO diff --git a/docs/concepts.rst b/docs/concepts.rst index 5b470fc..a4b865b 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -94,7 +94,7 @@ single process tied to a WSGI server, Django runs in three separate layers: * The workers, that listen on all relevant channels and run consumer code when a message is ready. -This may seem quite simplistic, but that's part of the design; rather than +This may seem relatively simplistic, but that's part of the design; rather than try and have a full asynchronous architecture, we're just introducing a slightly more complex abstraction than that presented by Django views. @@ -108,22 +108,25 @@ message. Suddenly, a view is merely another example of a consumer:: # Listens on http.request def my_consumer(message): - # Decode the request from JSON-compat to a full object - django_request = Request.channel_decode(message.content) + # Decode the request from message format to a Request object + django_request = AsgiRequest(message) # Run view django_response = view(django_request) - # Encode the response into JSON-compat format - message.reply_channel.send(django_response.channel_encode()) + # Encode the response into message format + for chunk in AsgiHandler.encode_response(django_response): + message.reply_channel.send(chunk) In fact, this is how Channels works. The interface servers transform connections from the outside world (HTTP, WebSockets, etc.) into messages on channels, -and then you write workers to handle these messages. +and then you write workers to handle these messages. Usually you leave normal +HTTP up to Django's built-in consumers that plug it into the view/template +system, but you can override it to add functionality if you want. -However, the key here is that you can run code (and so send on channels) in +However, the crucial part is that you can run code (and so send on channels) in response to any event - and that includes ones you create. You can trigger on model saves, on other incoming messages, or from code paths inside views and forms. That approach comes in handy for push-style -code - where you use HTML5's server-sent events or a WebSocket to notify +code - where you WebSockets or HTTP long-polling to notify clients of changes in real time (messages in a chat, perhaps, or live updates in an admin as another user edits something). @@ -168,8 +171,8 @@ Because channels only deliver to a single listener, they can't do broadcast; if you want to send a message to an arbitrary group of clients, you need to keep track of which response channels of those you wish to send to. -Say I had a live blog where I wanted to push out updates whenever a new post is -saved, I would register a handler for the ``post_save`` signal and keep a +If I had a liveblog where I wanted to push out updates whenever a new post is +saved, I could register a handler for the ``post_save`` signal and keep a set of channels (here, using Redis) to send updates to:: redis_conn = redis.Redis("localhost", 6379) @@ -194,7 +197,7 @@ listens to ``websocket.disconnect`` to do that, but we'd also need to have some kind of expiry in case an interface server is forced to quit or loses power before it can send disconnect signals - your code will never see any disconnect notification but the response channel is completely -invalid and messages you send there will never get consumed and just expire. +invalid and messages you send there will sit there until they expire. Because the basic design of channels is stateless, the channel server has no concept of "closing" a channel if an interface server goes away - after all, @@ -202,15 +205,17 @@ channels are meant to hold messages until a consumer comes along (and some types of interface server, e.g. an SMS gateway, could theoretically serve any client from any interface server). -That means that we need to follow a keepalive model, where the interface server -(or, if you want even better accuracy, the client browser/connection) sends -a periodic message saying it's still connected (though only for persistent -connection types like WebSockets; normal HTTP doesn't need this as it won't -stay connected for more than its own timeout). +We don't particularly care if a disconnected client doesn't get the messages +sent to the group - after all, it disconnected - but we do care about +cluttering up the channel backend tracking all of these clients that are no +longer around (and possibly, eventually getting a collision on the reply +channel name and sending someone messages not meant for them, though that would +likely take weeks). Now, we could go back into our example above and add an expiring set and keep -track of expiry times and so forth, but this is such a common pattern that -we don't need to; Channels has it built in, as a feature called Groups:: +track of expiry times and so forth, but what would be the point of a framework +if it made you add boilerplate code? Instead, Channels implements this +abstraction as a core concept called Groups:: @receiver(post_save, sender=BlogUpdate) def send_update(sender, instance, **kwargs): @@ -219,19 +224,27 @@ we don't need to; Channels has it built in, as a feature called Groups:: content=instance.content, ) - # Connected to websocket.connect and websocket.keepalive + # Connected to websocket.connect def ws_connect(message): # Add to reader group Group("liveblog").add(message.reply_channel) + # Connected to websocket.disconnect + def ws_disconnect(message): + # Remove from reader group on clean disconnect + Group("liveblog").discard(message.reply_channel) + Not only do groups have their own ``send()`` method (which backends can provide an efficient implementation of), they also automatically manage expiry of -the group members. You'll have to re-call ``Group.add()`` every so often to -keep existing members from expiring, but that's easy, and can be done in the -same handler for both ``connect`` and ``keepalive``, as you can see above. +the group members - when the channel starts having messages expire on it due +to non-consumption, we go in and remove it from all the groups it's in as well. +Of course, you should still remove things from the group on disconnect if you +can; the expiry code is there to catch cases where the disconnect message +doesn't make it for some reason. Groups are generally only useful for response channels (ones starting with -the character ``!``), as these are unique-per-client. +the character ``!``), as these are unique-per-client, but can be used for +normal channels as well if you wish. Next Steps ---------- diff --git a/docs/getting-started.rst b/docs/getting-started.rst index c6924ee..d66a98a 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -18,34 +18,40 @@ only one consumer can listen to any given channel. As a very basic example, let's write a consumer that overrides the built-in handling and handles every HTTP request directly. This isn't something you'd -usually do in a project, but it's a good illustration of how channels -now underlie every part of Django. +usually do in a project, but it's a good illustration of how Channels +actually underlies even core Django. Make a new project, a new app, and put this in a ``consumers.py`` file in the app:: from django.http import HttpResponse + from channels.handler import AsgiHandler def http_consumer(message): + # Make standard HTTP response - access ASGI path attribute directly response = HttpResponse("Hello world! You asked for %s" % message.content['path']) - message.reply_channel.send(response.channel_encode()) + # Encode that response into message format (ASGI) + for chunk in AsgiHandler.encode_response(response): + message.reply_channel.send(chunk) The most important thing to note here is that, because things we send in messages must be JSON serializable, the request and response messages -are in a key-value format. There are ``channel_decode()`` and -``channel_encode()`` methods on both Django's request and response classes, -but here we just use the message's ``content`` attribute directly for simplicity -(message content is always a dict). +are in a key-value format. You can read more about that format in the +:doc:`ASGI specification `, but you don't need to worry about it too much; +just know that there's an ``AsgiRequest`` class that translates from ASGI into +Django request objects, and the ``AsgiHandler`` class handles translation of +``HttpResponse`` into ASGI messages, which you see used above. Usually, +Django's built-in code will do all this for you when you're using normal views. -Now, go into your ``settings.py`` file, and set up a channel backend; by default, -Django will just use a local backend and route HTTP requests to the normal -URL resolver (we'll come back to backends in a minute). +Now, go into your ``settings.py`` file, and set up a channel layer; by default, +Django will just use an in-memory layer and route HTTP requests to the normal +URL resolver (we'll come back to channel layers in a minute). For now, we want to override the *channel routing* so that, rather than going to the URL resolver and our normal view stack, all HTTP requests go to our custom consumer we wrote above. Here's what that looks like:: # In settings.py - CHANNEL_BACKENDS = { + CHANNEL_LAYERS = { "default": { "BACKEND": "channels.database_layer.DatabaseChannelLayer", "ROUTING": "myproject.routing.channel_routing", @@ -58,11 +64,12 @@ custom consumer we wrote above. Here's what that looks like:: } As you can see, this is a little like Django's ``DATABASES`` setting; there are -named channel backends, with a default one called ``default``. Each backend +named channel layers, with a default one called ``default``. Each layer needs a class specified which powers it - we'll come to the options there later - and a routing scheme, which points to a dict containing the routing settings. It's recommended you call this ``routing.py`` and put it alongside ``urls.py`` -in your project. +in your project, but you can put it wherever you like, as long as the path is +correct. If you start up ``python manage.py runserver`` and go to ``http://localhost:8000``, you'll see that, rather than a default Django page, @@ -74,7 +81,8 @@ been able to do for a long time. Let's try some WebSockets, and make a basic chat server! Delete that consumer and its routing - we'll want the normal Django view layer to -serve HTTP requests from now on - and make this WebSocket consumer instead:: +serve HTTP requests from now on, which happens if you don't specify a consumer +for ``http.request`` - and make this WebSocket consumer instead:: # In consumers.py from channels import Group @@ -85,8 +93,10 @@ serve HTTP requests from now on - and make this WebSocket consumer instead:: Hook it up to the ``websocket.connect`` channel like this:: # In routing.py + from myproject.myapp.consumers import ws_add + channel_routing = { - "websocket.connect": "myproject.myapp.consumers.ws_add", + "websocket.connect": ws_add, } Now, let's look at what this is doing. It's tied to the @@ -98,39 +108,15 @@ is the unique response channel for that client, and adds it to the ``chat`` group, which means we can send messages to all connected chat clients. Of course, if you've read through :doc:`concepts`, you'll know that channels -added to groups expire out after a while unless you keep renewing their -membership. This is because Channels is stateless; the worker processes -don't keep track of the open/close states of the potentially thousands of -connections you have open at any one time. +added to groups expire out if their messages expire (every channel layer has +a message expiry time, usually between 30 seconds and a few minutes, and it's +often configurable). -The solution to this is that the WebSocket interface servers will send -periodic "keepalive" messages on the ``websocket.keepalive`` channel, -so we can hook that up to re-add the channel:: - - # In consumers.py - from channels import Group - - # Connected to websocket.keepalive - def ws_keepalive(message): - Group("chat").add(message.reply_channel) - -It's safe to add the channel to a group it's already in - similarly, it's -safe to discard a channel from a group it's not in. -Of course, this is exactly the same code as the ``connect`` handler, so let's -just route both channels to the same consumer:: - - # In routing.py - channel_routing = { - "websocket.connect": "myproject.myapp.consumers.ws_add", - "websocket.keepalive": "myproject.myapp.consumers.ws_add", - } - -And, even though channels will expire out, let's add an explicit ``disconnect`` -handler to clean up as people disconnect (most channels will cleanly disconnect -and get this called):: - - # In consumers.py - from channels import Group +However, we'll still get disconnection messages most of the time when a +WebSocket disconnects; the expiry/garbage collection of group membership is +mostly there for when a disconnect message gets lost (channels are not +guaranteed delivery, just mostly reliable). Let's add an explicit disconnect +handler:: # Connected to websocket.disconnect def ws_disconnect(message): @@ -144,13 +130,17 @@ any message sent in to all connected clients. Here's all the code:: # In consumers.py from channels import Group - # Connected to websocket.connect and websocket.keepalive + # Connected to websocket.connect def ws_add(message): Group("chat").add(message.reply_channel) # Connected to websocket.receive def ws_message(message): - Group("chat").send(message.content) + # ASGI WebSocket packet-received and send-packet message types + # both have a "text" key for their textual data. + Group("chat").send({ + "text": "[user] %s" % message.content['text'], + }) # Connected to websocket.disconnect def ws_disconnect(message): @@ -158,11 +148,12 @@ any message sent in to all connected clients. Here's all the code:: And what our routing should look like in ``routing.py``:: + from myproject.myapp.consumers import ws_add, ws_message, ws_disconnect + channel_routing = { - "websocket.connect": "myproject.myapp.consumers.ws_add", - "websocket.keepalive": "myproject.myapp.consumers.ws_add", - "websocket.receive": "myproject.myapp.consumers.ws_message", - "websocket.disconnect": "myproject.myapp.consumers.ws_disconnect", + "websocket.connect": ws_add, + "websocket.receive": ws_message, + "websocket.disconnect": ws_disconnect, } With all that code, you now have a working set of a logic for a chat server. @@ -172,49 +163,52 @@ hard. Running with Channels --------------------- -Because Channels takes Django into a multi-process model, you can no longer -just run one process if you want to serve more than one protocol type. +Because Channels takes Django into a multi-process model, you no longer run +everything in one process along with a WSGI server (of course, you're still +free to do that if you don't want to use Channels). Instead, you run one or +more *interface servers*, and one or more *worker servers*, connected by +that *channel layer* you configured earlier. There are multiple kinds of "interface servers", and each one will service a -different type of request - one might do WSGI requests, one might handle -WebSockets, or you might have one that handles both. +different type of request - one might do both WebSocket and HTTP requests, while +another might act as an SMS message gateway, for example. These are separate from the "worker servers" where Django will run actual logic, -though, and so you'll need to configure a channel backend to allow the -channels to run over the network. By default, when you're using Django out of -the box, the channel backend is set to an in-memory one that only works in -process; this is enough to serve normal WSGI style requests (``runserver`` is -just running a WSGI interface and a worker in two separate threads), but now we want -WebSocket support we'll need a separate process to keep things clean. +though, and so the *channel layer* transports the content of channels across +the network. In a production scenario, you'd usually run *worker servers* +as a separate cluster from the *interface servers*, though of course you +can run both as separate processes on one machine too. -If you notice, in the example above we switched our default backend to the -database channel backend. This uses two tables -in the database to do message handling, and isn't particularly fast but -requires no extra dependencies. When you deploy to production, you'll want to -use a backend like the Redis backend that has much better throughput. +By default, Django doesn't have a channel layer configured - it doesn't need one to run +normal WSGI requests, after all. As soon as you try to add some consumers, +though, you'll need to configure one. + +In the example above we used the database channel layer implementation +as our default channel layer. This uses two tables +in the ``default`` database to do message handling, and isn't particularly fast but +requires no extra dependencies, so it's handy for development. +When you deploy to production, though, you'll want to +use a backend like the Redis backend that has much better throughput and +lower latency. The second thing, once we have a networked channel backend set up, is to make -sure we're running the WebSocket interface server. Even in development, we need -to do this; ``runserver`` will take care of normal Web requests and running -a worker for us, but WebSockets isn't compatible with WSGI and needs to run -separately. +sure we're running an interface server that's capable of serving WebSockets. +Luckily, installing Channels will also install ``daphne``, an interface server +that can handle both HTTP and WebSockets at the same time, and then ties this +in to run when you run ``runserver`` - you shouldn't notice any difference +from the normal Django ``runserver``, though some of the options may be a little +different. -The easiest way to do this is to use the ``runwsserver`` management command -that ships with Django; just make sure you've installed the latest release -of ``autobahn`` first:: +*(Under the hood, runserver is now running Daphne in one thread and a worker +with autoreload in another - it's basically a miniature version of a deployment, +but all in one process)* - pip install -U autobahn[twisted] - python manage.py runwsserver +Now, let's test our code. Open a browser and put the following into the +JavaScript console to open a WebSocket and send some data down it:: -Run that alongside ``runserver`` and you'll have two interface servers, a -worker thread, and the channel backend all connected and running. You can -even launch separate worker processes with ``runworker`` if you like (you'll -need at least one of those if you're not also running ``runserver``). - -Now, just open a browser and put the following into the JavaScript console -to test your new code:: - - socket = new WebSocket("ws://127.0.0.1:9000"); + // Note that the path doesn't matter right now; any WebSocket + // connection gets bumped over to WebSocket consumers + socket = new WebSocket("ws://127.0.0.1:8000/chat/"); socket.onmessage = function(e) { alert(e.data); } @@ -230,15 +224,16 @@ receive the message and show an alert, as any incoming message is sent to the been put into the ``chat`` group when they connected. Feel free to put some calls to ``print`` in your handler functions too, if you -like, so you can understand when they're called. If you run three or four -copies of ``runworker`` you'll probably be able to see the tasks running -on different workers. +like, so you can understand when they're called. You can also run separate +worker processes with ``manage.py runworker`` as well - if you do this, you +should see some of the consumers being handled in the ``runserver`` thread and +some in the separate worker process. Persisting Data --------------- -Echoing messages is a nice simple example, but it's -skirting around the real design pattern - persistent state for connections. +Echoing messages is a nice simple example, but it's ignoring the real +need for a system like this - persistent state for connections. Let's consider a basic chat site where a user requests a chat room upon initial connection, as part of the query string (e.g. ``wss://host/websocket?room=abc``). @@ -250,8 +245,8 @@ global variables or similar. Instead, the solution is to persist information keyed by the ``reply_channel`` in some other data store - sound familiar? This is what Django's session framework -does for HTTP requests, only there it uses cookies as the lookup key rather -than the ``reply_channel``. +does for HTTP requests, using a cookie as the key. Wouldn't it be useful if +we could get a session using the ``reply_channel`` as a key? Channels provides a ``channel_session`` decorator for this purpose - it provides you with an attribute called ``message.channel_session`` that acts @@ -273,11 +268,6 @@ name in the path of your WebSocket request (we'll ignore auth for now - that's n message.channel_session['room'] = room Group("chat-%s" % room).add(message.reply_channel) - # Connected to websocket.keepalive - @channel_session - def ws_keepalive(message): - Group("chat-%s" % message.channel_session['room']).add(message.reply_channel) - # Connected to websocket.receive @channel_session def ws_message(message): @@ -316,16 +306,16 @@ In addition, we don't want the interface servers storing data or trying to run authentication; they're meant to be simple, lean, fast processes without much state, and so we'll need to do our authentication inside our consumer functions. -Fortunately, because Channels has standardised WebSocket event -:doc:`message-standards`, it ships with decorators that help you with +Fortunately, because Channels has an underlying spec for WebSockets and other +messages (:doc:`ASGI `), it ships with decorators that help you with both authentication and getting the underlying Django session (which is what Django authentication relies on). -Channels can use Django sessions either from cookies (if you're running your websocket -server on the same port as your main site, which requires a reverse proxy that -understands WebSockets), or from a ``session_key`` GET parameter, which -is much more portable, and works in development where you need to run a separate -WebSocket server (by default, on port 9000). +Channels can use Django sessions either from cookies (if you're running your +websocket server on the same port as your main site, using something like Daphne), +or from a ``session_key`` GET parameter, which is works if you want to keep +running your HTTP requests through a WSGI server and offload WebSockets to a +second server process on another port. You get access to a user's normal Django session using the ``http_session`` decorator - that gives you a ``message.http_session`` attribute that behaves @@ -334,12 +324,12 @@ which will provide a ``message.user`` attribute as well as the session attribute Now, one thing to note is that you only get the detailed HTTP information during the ``connect`` message of a WebSocket connection (you can read more -about what you get when in :doc:`message-standards`) - this means we're not +about that in the :doc:`ASGI spec `) - this means we're not wasting bandwidth sending the same information over the wire needlessly. This also means we'll have to grab the user in the connection handler and then store it in the session; thankfully, Channels ships with both a ``channel_session_user`` -decorator that works like the ``http_session_user`` decorator you saw above but +decorator that works like the ``http_session_user`` decorator we mentioned above but loads the user from the *channel* session rather than the *HTTP* session, and a function called ``transfer_user`` which replicates a user from one session to another. @@ -361,12 +351,6 @@ chat to people with the same first letter of their username:: # Add them to the right group Group("chat-%s" % message.user.username[0]).add(message.reply_channel) - # Connected to websocket.keepalive - @channel_session_user - def ws_keepalive(message): - # Keep them in the right group - Group("chat-%s" % message.user.username[0]).add(message.reply_channel) - # Connected to websocket.receive @channel_session_user def ws_message(message): @@ -377,7 +361,9 @@ chat to people with the same first letter of their username:: def ws_disconnect(message): Group("chat-%s" % message.user.username[0]).discard(message.reply_channel) -Now, when we connect to the WebSocket we'll have to remember to provide the +If you're just using ``runserver`` (and so Daphne), you can just connect +and your cookies should transfer your auth over. If you were running WebSockets +on a separate port, you'd have to remember to provide the Django session ID as part of the URL, like this:: socket = new WebSocket("ws://127.0.0.1:9000/?session_key=abcdefg"); @@ -437,11 +423,6 @@ have a ChatMessage model with ``message`` and ``room`` fields:: message.channel_session['room'] = room Group("chat-%s" % room).add(message.reply_channel) - # Connected to websocket.keepalive - @channel_session - def ws_add(message): - Group("chat-%s" % message.channel_session['room']).add(message.reply_channel) - # Connected to websocket.receive @channel_session def ws_message(message):