mirror of
https://github.com/django/daphne.git
synced 2025-07-13 01:12:17 +03:00
Stop using @consumer, move to explicit routing
This commit is contained in:
parent
29eb75326f
commit
832809ca25
|
@ -20,8 +20,8 @@ class BaseChannelBackend(object):
|
||||||
# Causes errors if you try to run workers/interfaces separately with it.
|
# Causes errors if you try to run workers/interfaces separately with it.
|
||||||
local_only = False
|
local_only = False
|
||||||
|
|
||||||
def __init__(self, expiry=60):
|
def __init__(self, routing, expiry=60):
|
||||||
self.registry = ConsumerRegistry()
|
self.registry = ConsumerRegistry(routing)
|
||||||
self.expiry = expiry
|
self.expiry = expiry
|
||||||
|
|
||||||
def send(self, channel, message):
|
def send(self, channel, message):
|
||||||
|
|
|
@ -16,8 +16,8 @@ class DatabaseChannelBackend(BaseChannelBackend):
|
||||||
multiple processes fine, but it's going to be pretty bad at throughput.
|
multiple processes fine, but it's going to be pretty bad at throughput.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, expiry=60, db_alias=DEFAULT_DB_ALIAS):
|
def __init__(self, routing, expiry=60, db_alias=DEFAULT_DB_ALIAS):
|
||||||
super(DatabaseChannelBackend, self).__init__(expiry)
|
super(DatabaseChannelBackend, self).__init__(routing=routing, expiry=expiry)
|
||||||
self.db_alias = db_alias
|
self.db_alias = db_alias
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -13,8 +13,8 @@ class RedisChannelBackend(BaseChannelBackend):
|
||||||
multiple processes fine, but it's going to be pretty bad at throughput.
|
multiple processes fine, but it's going to be pretty bad at throughput.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, expiry=60, host="localhost", port=6379, prefix="django-channels:"):
|
def __init__(self, routing, expiry=60, host="localhost", port=6379, prefix="django-channels:"):
|
||||||
super(RedisChannelBackend, self).__init__(expiry)
|
super(RedisChannelBackend, self).__init__(routing=routing, expiry=expiry)
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import functools
|
import importlib
|
||||||
|
|
||||||
from django.utils import six
|
from django.utils import six
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from .utils import name_that_thing
|
from .utils import name_that_thing
|
||||||
|
|
||||||
|
|
||||||
|
@ -13,13 +12,32 @@ class ConsumerRegistry(object):
|
||||||
Generally this is attached to a backend instance as ".registry"
|
Generally this is attached to a backend instance as ".registry"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, routing=None):
|
||||||
self.consumers = {}
|
self.consumers = {}
|
||||||
|
# Initialise with any routing that was passed in
|
||||||
|
if routing:
|
||||||
|
# If the routing was a string, import it
|
||||||
|
if isinstance(routing, six.string_types):
|
||||||
|
module_name, variable_name = routing.rsplit(".", 1)
|
||||||
|
try:
|
||||||
|
routing = getattr(importlib.import_module(module_name), variable_name)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
raise ImproperlyConfigured("Cannot import channel routing %r" % routing)
|
||||||
|
# Load consumers into us
|
||||||
|
for channel, handler in routing.items():
|
||||||
|
self.add_consumer(handler, [channel])
|
||||||
|
|
||||||
def add_consumer(self, consumer, channels):
|
def add_consumer(self, consumer, channels):
|
||||||
# Upconvert if you just pass in a string
|
# Upconvert if you just pass in a string for channels
|
||||||
if isinstance(channels, six.string_types):
|
if isinstance(channels, six.string_types):
|
||||||
channels = [channels]
|
channels = [channels]
|
||||||
|
# Import any consumer referenced as string
|
||||||
|
if isinstance(consumer, six.string_types):
|
||||||
|
module_name, variable_name = consumer.rsplit(".", 1)
|
||||||
|
try:
|
||||||
|
consumer = getattr(importlib.import_module(module_name), variable_name)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
raise ImproperlyConfigured("Cannot import consumer %r" % consumer)
|
||||||
# Register on each channel, checking it's unique
|
# Register on each channel, checking it's unique
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
if channel in self.consumers:
|
if channel in self.consumers:
|
||||||
|
|
|
@ -9,26 +9,6 @@ from django.contrib import auth
|
||||||
from channels import channel_backends, DEFAULT_CHANNEL_BACKEND
|
from channels import channel_backends, DEFAULT_CHANNEL_BACKEND
|
||||||
|
|
||||||
|
|
||||||
def consumer(*channels, **kwargs):
|
|
||||||
"""
|
|
||||||
Decorator that registers a function as a consumer.
|
|
||||||
"""
|
|
||||||
# We can't put a kwarg after *args in py2
|
|
||||||
alias = kwargs.get("alias", DEFAULT_CHANNEL_BACKEND)
|
|
||||||
# Upconvert if you just pass in a string
|
|
||||||
if isinstance(channels, six.string_types):
|
|
||||||
channels = [channels]
|
|
||||||
# Get the channel
|
|
||||||
channel_backend = channel_backends[alias]
|
|
||||||
# Return a function that'll register whatever it wraps
|
|
||||||
def inner(func):
|
|
||||||
channel_backend.registry.add_consumer(func, channels)
|
|
||||||
return func
|
|
||||||
return inner
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: Sessions, auth
|
|
||||||
|
|
||||||
def http_session(func):
|
def http_session(func):
|
||||||
"""
|
"""
|
||||||
Wraps a HTTP or WebSocket consumer (or any consumer of messages
|
Wraps a HTTP or WebSocket consumer (or any consumer of messages
|
||||||
|
|
|
@ -17,14 +17,15 @@ channel if you don't provide another consumer that listens to it - remember,
|
||||||
only one consumer can listen to any given channel.
|
only one consumer can listen to any given channel.
|
||||||
|
|
||||||
As a very basic example, let's write a consumer that overrides the built-in
|
As a very basic example, let's write a consumer that overrides the built-in
|
||||||
handling and handles every HTTP request directly. Make a new project, a new
|
handling and handles every HTTP request directly. This isn't something you'd
|
||||||
app, and put this in a ``consumers.py`` file in the app::
|
usually do in a project, but it's a good illustration of how channels
|
||||||
|
now underlie every part of Django.
|
||||||
|
|
||||||
|
Make a new project, a new app, and put this in a ``consumers.py`` file in the app::
|
||||||
|
|
||||||
from channels import Channel
|
from channels import Channel
|
||||||
from channels.decorators import consumer
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
@consumer("django.wsgi.request")
|
|
||||||
def http_consumer(response_channel, path, **kwargs):
|
def http_consumer(response_channel, path, **kwargs):
|
||||||
response = HttpResponse("Hello world! You asked for %s" % path)
|
response = HttpResponse("Hello world! You asked for %s" % path)
|
||||||
Channel(response_channel).send(**response.channel_encode())
|
Channel(response_channel).send(**response.channel_encode())
|
||||||
|
@ -36,6 +37,30 @@ are in a key-value format. There are ``channel_decode()`` and
|
||||||
but here we just take two of the request variables directly as keyword
|
but here we just take two of the request variables directly as keyword
|
||||||
arguments for simplicity.
|
arguments for simplicity.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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::
|
||||||
|
|
||||||
|
CHANNEL_BACKENDS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels.backends.database.DatabaseChannelBackend",
|
||||||
|
"ROUTING": {
|
||||||
|
"django.wsgi.request": "myproject.myapp.consumers.http_consumer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
needs a class specified which powers it - we'll come to the options there later -
|
||||||
|
and a routing scheme, which can either be defined directly as a dict or as
|
||||||
|
a string pointing to a dict in another file (if you'd rather keep it outside
|
||||||
|
settings).
|
||||||
|
|
||||||
If you start up ``python manage.py runserver`` and go to
|
If you start up ``python manage.py runserver`` and go to
|
||||||
``http://localhost:8000``, you'll see that, rather than a default Django page,
|
``http://localhost:8000``, you'll see that, rather than a default Django page,
|
||||||
you get the Hello World response, so things are working. If you don't see
|
you get the Hello World response, so things are working. If you don't see
|
||||||
|
@ -44,13 +69,23 @@ a response, check you :doc:`installed Channels correctly <installation>`.
|
||||||
Now, that's not very exciting - raw HTTP responses are something Django can
|
Now, that's not very exciting - raw HTTP responses are something Django can
|
||||||
do any time. Let's try some WebSockets, and make a basic chat server!
|
do any time. Let's try some WebSockets, and make a basic chat server!
|
||||||
|
|
||||||
Delete that consumer from above - we'll need the normal Django view layer to
|
Delete that consumer and its routing - we'll want the normal Django view layer to
|
||||||
serve templates later - and make this WebSocket consumer instead::
|
serve HTTP requests from now on - and make this WebSocket consumer instead::
|
||||||
|
|
||||||
@consumer("django.websocket.connect")
|
def ws_add(channel, send_channel, **kwargs):
|
||||||
def ws_connect(channel, send_channel, **kwargs):
|
|
||||||
Group("chat").add(send_channel)
|
Group("chat").add(send_channel)
|
||||||
|
|
||||||
|
Hook it up to the ``django.websocket.connect`` channel like this::
|
||||||
|
|
||||||
|
CHANNEL_BACKENDS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels.backends.database.DatabaseChannelBackend",
|
||||||
|
"ROUTING": {
|
||||||
|
"django.websocket.connect": "myproject.myapp.consumers.ws_add",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
Now, let's look at what this is doing. It's tied to the
|
Now, let's look at what this is doing. It's tied to the
|
||||||
``django.websocket.connect`` channel, which means that it'll get a message
|
``django.websocket.connect`` channel, which means that it'll get a message
|
||||||
whenever a new WebSocket connection is opened by a client.
|
whenever a new WebSocket connection is opened by a client.
|
||||||
|
@ -71,22 +106,25 @@ so we can hook that up to re-add the 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
|
a group it's already in - similarly, it's safe to discard a channel from a
|
||||||
group it's not in)::
|
group it's not in)::
|
||||||
|
|
||||||
@consumer("django.websocket.keepalive")
|
# Connected to django.websocket.keepalive
|
||||||
def ws_keepalive(channel, send_channel, **kwargs):
|
def ws_keepalive(channel, send_channel, **kwargs):
|
||||||
Group("chat").add(send_channel)
|
Group("chat").add(send_channel)
|
||||||
|
|
||||||
Of course, this is exactly the same code as the ``connect`` handler, so let's
|
Of course, this is exactly the same code as the ``connect`` handler, so let's
|
||||||
just combine them::
|
just route both channels to the same consumer::
|
||||||
|
|
||||||
@consumer("django.websocket.connect", "django.websocket.keepalive")
|
...
|
||||||
def ws_add(channel, send_channel, **kwargs):
|
"ROUTING": {
|
||||||
Group("chat").add(send_channel)
|
"django.websocket.connect": "myproject.myapp.consumers.ws_add",
|
||||||
|
"django.websocket.keepalive": "myproject.myapp.consumers.ws_add",
|
||||||
|
},
|
||||||
|
...
|
||||||
|
|
||||||
And, even though channels will expire out, let's add an explicit ``disconnect``
|
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
|
handler to clean up as people disconnect (most channels will cleanly disconnect
|
||||||
and get this called)::
|
and get this called)::
|
||||||
|
|
||||||
@consumer("django.websocket.disconnect")
|
# Connected to django.websocket.disconnect
|
||||||
def ws_disconnect(channel, send_channel, **kwargs):
|
def ws_disconnect(channel, send_channel, **kwargs):
|
||||||
Group("chat").discard(send_channel)
|
Group("chat").discard(send_channel)
|
||||||
|
|
||||||
|
@ -96,20 +134,33 @@ we're not going to store a history of messages or anything and just replay
|
||||||
any message sent in to all connected clients. Here's all the code::
|
any message sent in to all connected clients. Here's all the code::
|
||||||
|
|
||||||
from channels import Channel, Group
|
from channels import Channel, Group
|
||||||
from channels.decorators import consumer
|
|
||||||
|
|
||||||
@consumer("django.websocket.connect", "django.websocket.keepalive")
|
# Connected to django.websocket.connect and django.websocket.keepalive
|
||||||
def ws_add(channel, send_channel, **kwargs):
|
def ws_add(channel, send_channel, **kwargs):
|
||||||
Group("chat").add(send_channel)
|
Group("chat").add(send_channel)
|
||||||
|
|
||||||
@consumer("django.websocket.receive")
|
# Connected to django.websocket.receive
|
||||||
def ws_message(channel, send_channel, content, **kwargs):
|
def ws_message(channel, send_channel, content, **kwargs):
|
||||||
Group("chat").send(content=content)
|
Group("chat").send(content=content)
|
||||||
|
|
||||||
@consumer("django.websocket.disconnect")
|
# Connected to django.websocket.disconnect
|
||||||
def ws_disconnect(channel, send_channel, **kwargs):
|
def ws_disconnect(channel, send_channel, **kwargs):
|
||||||
Group("chat").discard(send_channel)
|
Group("chat").discard(send_channel)
|
||||||
|
|
||||||
|
And what our routing should look like in ``settings.py``::
|
||||||
|
|
||||||
|
CHANNEL_BACKENDS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels.backends.database.DatabaseChannelBackend",
|
||||||
|
"ROUTING": {
|
||||||
|
"django.websocket.connect": "myproject.myapp.consumers.ws_add",
|
||||||
|
"django.websocket.keepalive": "myproject.myapp.consumers.ws_add",
|
||||||
|
"django.websocket.receive": "myproject.myapp.consumers.ws_message",
|
||||||
|
"django.websocket.disconnect": "myproject.myapp.consumers.ws_disconnect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
With all that code in your ``consumers.py`` file, you now have a working
|
With all that code in your ``consumers.py`` file, you now have a working
|
||||||
set of a logic for a chat server. All you need to do now is get it deployed,
|
set of a logic for a chat server. All you need to do now is get it deployed,
|
||||||
and as we'll see, that's not too hard.
|
and as we'll see, that's not too hard.
|
||||||
|
@ -132,23 +183,11 @@ 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
|
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.
|
WebSocket support we'll need a separate process to keep things clean.
|
||||||
|
|
||||||
For simplicity, we'll use the database channel backend - this uses two tables
|
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
|
in the database to do message handling, and isn't particularly fast but
|
||||||
requires no extra dependencies. Put this in your ``settings.py`` file::
|
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.
|
||||||
CHANNEL_BACKENDS = {
|
|
||||||
"default": {
|
|
||||||
"BACKEND": "channels.backends.database.DatabaseChannelBackend",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
As you can see, the format is quite similar to the ``DATABASES`` setting in
|
|
||||||
Django, but for this case much simpler, as it just uses the default database
|
|
||||||
(you can set which alias it uses with the ``DB_ALIAS`` key).
|
|
||||||
|
|
||||||
In production, we'd recommend you use something like the Redis channel backend;
|
|
||||||
you can :doc:`read about the backends <backends>` and see how to set them up
|
|
||||||
and their performance considerations if you wish.
|
|
||||||
|
|
||||||
The second thing, once we have a networked channel backend set up, is to make
|
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
|
sure we're running the WebSocket interface server. Even in development, we need
|
||||||
|
@ -182,7 +221,9 @@ to test your new code::
|
||||||
You should see an alert come back immediately saying "hello world" - your
|
You should see an alert come back immediately saying "hello world" - your
|
||||||
message has round-tripped through the server and come back to trigger the alert.
|
message has round-tripped through the server and come back to trigger the alert.
|
||||||
You can open another tab and do the same there if you like, and both tabs will
|
You can open another tab and do the same there if you like, and both tabs will
|
||||||
receive the message and show an alert.
|
receive the message and show an alert, as any incoming message is sent to the
|
||||||
|
``chat`` group by the ``ws_message`` consumer, and both your tabs will have
|
||||||
|
been put into the ``chat`` group when they connected.
|
||||||
|
|
||||||
Feel free to put some calls to ``print`` in your handler functions too, if you
|
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
|
like, so you can understand when they're called. If you run three or four
|
||||||
|
@ -204,11 +245,10 @@ user ID, I can just auto-add that channel to all the relevant groups (mentions
|
||||||
of that user, for example).
|
of that user, for example).
|
||||||
|
|
||||||
Handily, as WebSockets start off using the HTTP protocol, they have a lot of
|
Handily, as WebSockets start off using the HTTP protocol, they have a lot of
|
||||||
familiar features, including a path, GET parameters, and cookies. Notably,
|
familiar features, including a path, GET parameters, and cookies. We'd like to
|
||||||
the cookies allow us to perform authentication using the same methods the
|
use these to hook into the familiar Django session and authentication systems;
|
||||||
normal Django middleware does. Middleware only runs on requests to views,
|
after all, WebSockets are no good unless we can identify who they belong to
|
||||||
however, and not on raw consumer calls; it's tailored to work with single
|
and do things securely.
|
||||||
HTTP requests, and so we need a different solution to authenticate WebSockets.
|
|
||||||
|
|
||||||
In addition, we don't want the interface servers storing data or trying to run
|
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
|
authentication; they're meant to be simple, lean, fast processes without much
|
||||||
|
@ -217,7 +257,11 @@ state, and so we'll need to do our authentication inside our consumer functions.
|
||||||
Fortunately, because Channels has standardised WebSocket event
|
Fortunately, because Channels has standardised WebSocket event
|
||||||
:doc:`message-standards`, it ships with decorators that help you with
|
:doc:`message-standards`, it ships with decorators that help you with
|
||||||
authentication, as well as using Django's session framework (which authentication
|
authentication, as well as using Django's session framework (which authentication
|
||||||
relies on).
|
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).
|
||||||
|
|
||||||
All we need to do is add the ``django_http_auth`` decorator to our views,
|
All we need to do is add the ``django_http_auth`` decorator to our views,
|
||||||
and we'll get extra ``session`` and ``user`` keyword arguments we can use;
|
and we'll get extra ``session`` and ``user`` keyword arguments we can use;
|
||||||
|
@ -225,26 +269,33 @@ let's make one where users can only chat to people with the same first letter
|
||||||
of their username::
|
of their username::
|
||||||
|
|
||||||
from channels import Channel, Group
|
from channels import Channel, Group
|
||||||
from channels.decorators import consumer, django_http_auth
|
from channels.decorators import django_http_auth
|
||||||
|
|
||||||
@consumer("django.websocket.connect", "django.websocket.keepalive")
|
|
||||||
@django_http_auth
|
@django_http_auth
|
||||||
def ws_add(channel, send_channel, user, **kwargs):
|
def ws_add(channel, send_channel, user, **kwargs):
|
||||||
Group("chat-%s" % user.username[0]).add(send_channel)
|
Group("chat-%s" % user.username[0]).add(send_channel)
|
||||||
|
|
||||||
@consumer("django.websocket.receive")
|
|
||||||
@django_http_auth
|
@django_http_auth
|
||||||
def ws_message(channel, send_channel, content, user, **kwargs):
|
def ws_message(channel, send_channel, content, user, **kwargs):
|
||||||
Group("chat-%s" % user.username[0]).send(content=content)
|
Group("chat-%s" % user.username[0]).send(content=content)
|
||||||
|
|
||||||
@consumer("django.websocket.disconnect")
|
|
||||||
@django_http_auth
|
@django_http_auth
|
||||||
def ws_disconnect(channel, send_channel, user, **kwargs):
|
def ws_disconnect(channel, send_channel, user, **kwargs):
|
||||||
Group("chat-%s" % user.username[0]).discard(send_channel)
|
Group("chat-%s" % user.username[0]).discard(send_channel)
|
||||||
|
|
||||||
(Note that we always end consumers with ``**kwargs``; this is to save us
|
Now, when we connect to the WebSocket we'll 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");
|
||||||
|
|
||||||
|
You can get the current session key in a template with ``{{ request.session.session_key }}``.
|
||||||
|
Note that Channels can't work with signed cookie sessions - since only HTTP
|
||||||
|
responses can set cookies, it needs a backend it can write to separately to
|
||||||
|
store state.
|
||||||
|
|
||||||
|
(Also note that we always end consumers with ``**kwargs``; this is to save us
|
||||||
from writing out all variables we might get sent and to allow forwards-compatibility
|
from writing out all variables we might get sent and to allow forwards-compatibility
|
||||||
with any additions to the message formats in the future)
|
with any additions to the message formats in the future.)
|
||||||
|
|
||||||
Persisting Data
|
Persisting Data
|
||||||
---------------
|
---------------
|
||||||
|
|
Loading…
Reference in New Issue
Block a user