From 832809ca25bc7b42ae7d92ee1a2fcde8f4ea9eb9 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 3 Sep 2015 00:07:30 -0700 Subject: [PATCH] Stop using @consumer, move to explicit routing --- channels/backends/base.py | 4 +- channels/backends/database.py | 4 +- channels/backends/redis_py.py | 4 +- channels/consumer_registry.py | 28 +++++-- channels/decorators.py | 20 ----- docs/getting-started.rst | 145 +++++++++++++++++++++++----------- 6 files changed, 127 insertions(+), 78 deletions(-) diff --git a/channels/backends/base.py b/channels/backends/base.py index 7a99568..84e874e 100644 --- a/channels/backends/base.py +++ b/channels/backends/base.py @@ -20,8 +20,8 @@ class BaseChannelBackend(object): # Causes errors if you try to run workers/interfaces separately with it. local_only = False - def __init__(self, expiry=60): - self.registry = ConsumerRegistry() + def __init__(self, routing, expiry=60): + self.registry = ConsumerRegistry(routing) self.expiry = expiry def send(self, channel, message): diff --git a/channels/backends/database.py b/channels/backends/database.py index ed59a9e..504f3ad 100644 --- a/channels/backends/database.py +++ b/channels/backends/database.py @@ -16,8 +16,8 @@ class DatabaseChannelBackend(BaseChannelBackend): multiple processes fine, but it's going to be pretty bad at throughput. """ - def __init__(self, expiry=60, db_alias=DEFAULT_DB_ALIAS): - super(DatabaseChannelBackend, self).__init__(expiry) + def __init__(self, routing, expiry=60, db_alias=DEFAULT_DB_ALIAS): + super(DatabaseChannelBackend, self).__init__(routing=routing, expiry=expiry) self.db_alias = db_alias @property diff --git a/channels/backends/redis_py.py b/channels/backends/redis_py.py index 11d3834..5c8671c 100644 --- a/channels/backends/redis_py.py +++ b/channels/backends/redis_py.py @@ -13,8 +13,8 @@ class RedisChannelBackend(BaseChannelBackend): 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:"): - super(RedisChannelBackend, self).__init__(expiry) + def __init__(self, routing, expiry=60, host="localhost", port=6379, prefix="django-channels:"): + super(RedisChannelBackend, self).__init__(routing=routing, expiry=expiry) self.host = host self.port = port self.prefix = prefix diff --git a/channels/consumer_registry.py b/channels/consumer_registry.py index 5e80729..cefe4c6 100644 --- a/channels/consumer_registry.py +++ b/channels/consumer_registry.py @@ -1,7 +1,6 @@ -import functools - +import importlib from django.utils import six - +from django.core.exceptions import ImproperlyConfigured from .utils import name_that_thing @@ -13,13 +12,32 @@ class ConsumerRegistry(object): Generally this is attached to a backend instance as ".registry" """ - def __init__(self): + def __init__(self, routing=None): 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): - # Upconvert if you just pass in a string + # Upconvert if you just pass in a string for channels if isinstance(channels, six.string_types): 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 for channel in channels: if channel in self.consumers: diff --git a/channels/decorators.py b/channels/decorators.py index 367681a..bd70058 100644 --- a/channels/decorators.py +++ b/channels/decorators.py @@ -9,26 +9,6 @@ from django.contrib import auth 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): """ Wraps a HTTP or WebSocket consumer (or any consumer of messages diff --git a/docs/getting-started.rst b/docs/getting-started.rst index c216c0b..9a79b80 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -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. 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 -app, and put this in a ``consumers.py`` file in the app:: +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. + +Make a new project, a new app, and put this in a ``consumers.py`` file in the app:: from channels import Channel - from channels.decorators import consumer from django.http import HttpResponse - @consumer("django.wsgi.request") def http_consumer(response_channel, path, **kwargs): response = HttpResponse("Hello world! You asked for %s" % path) 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 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 ``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 @@ -44,13 +69,23 @@ a response, check you :doc:`installed Channels correctly `. 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! -Delete that consumer from above - we'll need the normal Django view layer to -serve templates later - and make this WebSocket consumer instead:: +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:: - @consumer("django.websocket.connect") - def ws_connect(channel, send_channel, **kwargs): + def ws_add(channel, send_channel, **kwargs): 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 ``django.websocket.connect`` channel, which means that it'll get a message 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 group it's not in):: - @consumer("django.websocket.keepalive") + # Connected to django.websocket.keepalive def ws_keepalive(channel, send_channel, **kwargs): Group("chat").add(send_channel) 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): - Group("chat").add(send_channel) + ... + "ROUTING": { + "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`` handler to clean up as people disconnect (most channels will cleanly disconnect and get this called):: - @consumer("django.websocket.disconnect") + # Connected to django.websocket.disconnect def ws_disconnect(channel, send_channel, **kwargs): 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:: 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): Group("chat").add(send_channel) - @consumer("django.websocket.receive") + # Connected to django.websocket.receive def ws_message(channel, send_channel, content, **kwargs): Group("chat").send(content=content) - @consumer("django.websocket.disconnect") + # Connected to django.websocket.disconnect def ws_disconnect(channel, send_channel, **kwargs): 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 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. @@ -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 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 -requires no extra dependencies. Put this in your ``settings.py`` file:: - - 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 ` and see how to set them up -and their performance considerations if you wish. +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. 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 @@ -182,7 +221,9 @@ to test your new code:: 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. 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 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). Handily, as WebSockets start off using the HTTP protocol, they have a lot of -familiar features, including a path, GET parameters, and cookies. Notably, -the cookies allow us to perform authentication using the same methods the -normal Django middleware does. Middleware only runs on requests to views, -however, and not on raw consumer calls; it's tailored to work with single -HTTP requests, and so we need a different solution to authenticate WebSockets. +familiar features, including a path, GET parameters, and cookies. We'd like to +use these to hook into the familiar Django session and authentication systems; +after all, WebSockets are no good unless we can identify who they belong to +and do things securely. 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 @@ -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 :doc:`message-standards`, it ships with decorators that help you with 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, 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:: 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 def ws_add(channel, send_channel, user, **kwargs): Group("chat-%s" % user.username[0]).add(send_channel) - @consumer("django.websocket.receive") @django_http_auth def ws_message(channel, send_channel, content, user, **kwargs): Group("chat-%s" % user.username[0]).send(content=content) - @consumer("django.websocket.disconnect") @django_http_auth def ws_disconnect(channel, send_channel, user, **kwargs): 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 -with any additions to the message formats in the future) +with any additions to the message formats in the future.) Persisting Data ---------------