From 62d4782dbd497d04f78e23f0f14b05d030509fe5 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 14 Jul 2016 23:15:57 -0700 Subject: [PATCH 01/10] First version of binding code --- channels/apps.py | 4 + channels/binding/__init__.py | 1 + channels/binding/base.py | 176 +++++++++++++++++++++++++++++++++ channels/binding/websockets.py | 79 +++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 channels/binding/__init__.py create mode 100644 channels/binding/base.py create mode 100644 channels/binding/websockets.py diff --git a/channels/apps.py b/channels/apps.py index fec19cd..f2d7874 100644 --- a/channels/apps.py +++ b/channels/apps.py @@ -1,6 +1,8 @@ from django.apps import AppConfig from django.core.exceptions import ImproperlyConfigured +from .binding.base import BindingMetaclass + class ChannelsConfig(AppConfig): @@ -18,3 +20,5 @@ class ChannelsConfig(AppConfig): # Do django monkeypatches from .hacks import monkeypatch_django monkeypatch_django() + # Instantiate bindings + BindingMetaclass.register_all() diff --git a/channels/binding/__init__.py b/channels/binding/__init__.py new file mode 100644 index 0000000..5e54ac1 --- /dev/null +++ b/channels/binding/__init__.py @@ -0,0 +1 @@ +from .base import Binding diff --git a/channels/binding/base.py b/channels/binding/base.py new file mode 100644 index 0000000..39db69a --- /dev/null +++ b/channels/binding/base.py @@ -0,0 +1,176 @@ +from __future__ import unicode_literals + +import six + +from django.apps import apps +from django.db.models.signals import post_save, post_delete + +from ..channel import Group + + +class BindingMetaclass(type): + """ + Metaclass that tracks instantiations of its type. + """ + + binding_classes = [] + + def __new__(cls, name, bases, body): + klass = type.__new__(cls, name, bases, body) + if bases != (object, ): + cls.binding_classes.append(klass) + return klass + + @classmethod + def register_all(cls): + for binding_class in cls.binding_classes: + binding_class.register() + + +@six.add_metaclass(BindingMetaclass) +class Binding(object): + """ + Represents a two-way data binding from channels/groups to a Django model. + Outgoing binding sends model events to zero or more groups. + Incoming binding takes messages and maybe applies the action based on perms. + + To implement outbound, implement: + - group_names, which returns a list of group names to send to + - serialize, which returns message contents from an instance + action + + To implement inbound, implement: + - deserialize, which returns pk, data and action from message contents + - has_permission, which says if the user can do the action on an instance + - create, which takes the data and makes a model instance + - update, which takes data and a model instance and applies one to the other + + Outbound will work once you implement the functions; inbound requires you + to place one or more bindings inside a protocol-specific Demultiplexer + and tie that in as a consumer. + """ + + model = None + + @classmethod + def register(cls): + """ + Resolves models. + """ + # If model is None directly on the class, assume it's abstract. + if cls.model is None: + if "model" in cls.__dict__: + return + else: + raise ValueError("You must set the model attribute on Binding %r!" % cls) + # Optionally resolve model strings + if isinstance(cls.model, six.string_types): + cls.model = apps.get_model(cls.model) + # Connect signals + post_save.connect(cls.save_receiver, sender=cls.model) + post_delete.connect(cls.delete_receiver, sender=cls.model) + + # Outbound binding + + @classmethod + def save_receiver(cls, instance, created, **kwargs): + """ + Entry point for triggering the binding from save signals. + """ + cls.trigger_outbound(instance, "create" if created else "update") + + @classmethod + def delete_receiver(cls, instance, **kwargs): + """ + Entry point for triggering the binding from save signals. + """ + cls.trigger_outbound(instance, "delete") + + @classmethod + def trigger_outbound(cls, instance, action): + """ + Triggers the binding to possibly send to its group. + """ + self = cls() + self.instance = instance + # Check to see if we're covered + for group_name in self.group_names(instance, action): + group = Group(group_name) + group.send(self.serialize(instance, action)) + + def group_names(self, instance, action): + """ + Returns the iterable of group names to send the object to based on the + instance and action performed on it. + """ + raise NotImplementedError() + + def serialize(self, instance, action): + """ + Should return a serialized version of the instance to send over the + wire (return value must be a dict suitable for sending over a channel - + e.g., to send JSON as a WebSocket text frame, you must return + {"text": json.dumps(instance_serialized_as_dict)} + """ + raise NotImplementedError() + + # Inbound binding + + @classmethod + def trigger_inbound(cls, message): + """ + Triggers the binding to see if it will do something. + We separate out message serialization to a consumer, so this gets + native arguments. + """ + # Late import as it touches models + from django.contrib.auth.models import AnonymousUser + self = cls() + self.message = message + # Deserialize message + self.action, self.pk, self.data = self.deserialize(self.message) + self.user = getattr(self.message, "user", AnonymousUser()) + # Run incoming action + self.run_action(self.action, self.pk, self.data) + + def deserialize(self, message): + """ + Returns action, pk, data decoded from the message. pk should be None + if action is create; data should be None if action is delete. + """ + raise NotImplementedError() + + def has_permission(self, user, action, pk): + """ + Return True if the user can do action to the pk, False if not. + User may be AnonymousUser if no auth hooked up/they're not logged in. + Action is one of "create", "delete", "update". + """ + raise NotImplementedError() + + def run_action(self, action, pk, data): + """ + Performs the requested action. This version dispatches to named + functions by default for update/create, and handles delete itself. + """ + # Check to see if we're allowed + if self.has_permission(self.user, pk): + if action == "create": + self.create(data) + elif action == "update": + self.update(self.model.get(pk=pk), data) + elif action == "delete": + self.model.filter(pk=pk).delete() + else: + raise ValueError("Bad action %r" % action) + + def create(self, data): + """ + Creates a new instance of the model with the data. + """ + raise NotImplementedError() + + def update(self, instance, data): + """ + Updates the model with the data. + """ + raise NotImplementedError() diff --git a/channels/binding/websockets.py b/channels/binding/websockets.py new file mode 100644 index 0000000..d664d27 --- /dev/null +++ b/channels/binding/websockets.py @@ -0,0 +1,79 @@ +import json + +from .base import Binding +from ..generic.websockets import JsonWebsocketConsumer + + +class WebsocketBinding(Binding): + """ + Websocket-specific outgoing binding subclass that uses JSON encoding. + + To implement outbound, implement: + - group_names, which returns a list of group names to send to + - serialize_data, which returns JSON-safe data from a model instance + + To implement inbound, implement: + - has_permission, which says if the user can do the action on an instance + - create, which takes incoming data and makes a model instance + - update, which takes incoming data and a model instance and applies one to the other + """ + + # Mark as abstract + + model = None + + # Outbound + + def serialize(self, instance, action): + return { + "text": json.dumps({ + "model": "%s.%s" % ( + instance._meta.app_label.lower(), + instance._meta.object_name.lower(), + ), + "action": action, + "pk": instance.pk, + "data": self.serialize_data(instance), + }), + } + + def serialize_data(self, instance): + """ + Serializes model data into JSON-compatible types. + """ + raise NotImplementedError() + + # Inbound + + def deserialize(self, message): + content = json.loads(message['text']) + action = content['action'] + pk = content.get('pk', None) + data = content.get('data', None) + return action, pk, data + + +class WebsocketBindingDemultiplexer(JsonWebsocketConsumer): + """ + Allows you to combine multiple Bindings as one websocket consumer. + Subclass and provide a custom list of Bindings. + """ + + http_user = True + warn_if_no_match = True + bindings = None + + def receive(self, content): + # Sanity check + if self.bindings is None: + raise ValueError("Demultiplexer has no bindings!") + # Find the matching binding + model_label = content['model'] + triggered = False + for binding in self.bindings: + if binding.model_label == model_label: + binding.trigger_inbound(self.message) + triggered = True + # At least one of them should have fired. + if not triggered and self.warn_if_no_match: + raise ValueError("No binding found for model %s" % model_label) From 15cc5571dab67d43dc721c46d14c677d1bbaf187 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 14 Jul 2016 23:34:12 -0700 Subject: [PATCH 02/10] Fix a few model bits on the bindings --- channels/binding/base.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/channels/binding/base.py b/channels/binding/base.py index 39db69a..d2b9728 100644 --- a/channels/binding/base.py +++ b/channels/binding/base.py @@ -65,6 +65,10 @@ class Binding(object): # Optionally resolve model strings if isinstance(cls.model, six.string_types): cls.model = apps.get_model(cls.model) + cls.model_label = "%s.%s" % ( + cls.model._meta.app_label.lower(), + cls.model._meta.object_name.lower(), + ) # Connect signals post_save.connect(cls.save_receiver, sender=cls.model) post_delete.connect(cls.delete_receiver, sender=cls.model) @@ -153,13 +157,13 @@ class Binding(object): functions by default for update/create, and handles delete itself. """ # Check to see if we're allowed - if self.has_permission(self.user, pk): + if self.has_permission(self.user, action, pk): if action == "create": self.create(data) elif action == "update": - self.update(self.model.get(pk=pk), data) + self.update(self.model.objects.get(pk=pk), data) elif action == "delete": - self.model.filter(pk=pk).delete() + self.model.objects.filter(pk=pk).delete() else: raise ValueError("Bad action %r" % action) From 8a107a543429dee02ff21e2b4c1d7b537f384ebd Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 14 Jul 2016 23:53:08 -0700 Subject: [PATCH 03/10] Fix QA error --- channels/binding/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/channels/binding/__init__.py b/channels/binding/__init__.py index 5e54ac1..37b12a0 100644 --- a/channels/binding/__init__.py +++ b/channels/binding/__init__.py @@ -1 +1 @@ -from .base import Binding +from .base import Binding # NOQA isort:skip From 6fd83f04f8b350dfaa880efc969649fecf44d5bd Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sat, 16 Jul 2016 23:04:32 -0700 Subject: [PATCH 04/10] Add group_channels --- docs/asgi.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/asgi.rst b/docs/asgi.rst index 85c7180..13f9b7b 100644 --- a/docs/asgi.rst +++ b/docs/asgi.rst @@ -348,6 +348,11 @@ A channel layer implementing the ``groups`` extension must also provide: * ``group_discard(group, channel)``, a callable that removes the ``channel`` from the ``group`` if it is in it, and does nothing otherwise. +* ``group_channels(group)``, a callable that returns an iterable which yields + all of the group's member channel names. The return value should be serializable + with regards to local adds and discards, but best-effort with regards to + adds and discards on other nodes. + * ``send_group(group, message)``, a callable that takes two positional arguments; the group to send to, as a unicode string, and the message to send, as a serializable ``dict``. It may raise MessageTooLarge but cannot From 5d2354c71b6094e2fa4eab7693f33d5af6436d73 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Mon, 18 Jul 2016 14:57:19 -0400 Subject: [PATCH 05/10] Provide default serializers for the JSON one --- channels/binding/base.py | 12 +++++++++--- channels/binding/websockets.py | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/channels/binding/base.py b/channels/binding/base.py index d2b9728..0afbdf1 100644 --- a/channels/binding/base.py +++ b/channels/binding/base.py @@ -161,9 +161,9 @@ class Binding(object): if action == "create": self.create(data) elif action == "update": - self.update(self.model.objects.get(pk=pk), data) + self.update(pk, data) elif action == "delete": - self.model.objects.filter(pk=pk).delete() + self.delete(pk) else: raise ValueError("Bad action %r" % action) @@ -173,8 +173,14 @@ class Binding(object): """ raise NotImplementedError() - def update(self, instance, data): + def update(self, pk, data): """ Updates the model with the data. """ raise NotImplementedError() + + def delete(self, pk): + """ + Deletes the model instance. + """ + self.model.objects.filter(pk=pk).delete() diff --git a/channels/binding/websockets.py b/channels/binding/websockets.py index d664d27..27f6563 100644 --- a/channels/binding/websockets.py +++ b/channels/binding/websockets.py @@ -1,5 +1,7 @@ import json +from django.core import serializers + from .base import Binding from ..generic.websockets import JsonWebsocketConsumer @@ -10,10 +12,12 @@ class WebsocketBinding(Binding): To implement outbound, implement: - group_names, which returns a list of group names to send to - - serialize_data, which returns JSON-safe data from a model instance To implement inbound, implement: - has_permission, which says if the user can do the action on an instance + + Optionally also implement: + - serialize_data, which returns JSON-safe data from a model instance - create, which takes incoming data and makes a model instance - update, which takes incoming data and a model instance and applies one to the other """ @@ -41,7 +45,8 @@ class WebsocketBinding(Binding): """ Serializes model data into JSON-compatible types. """ - raise NotImplementedError() + data = serializers.serialize('json', [instance]) + return json.loads(data)[0]['fields'] # Inbound @@ -52,6 +57,31 @@ class WebsocketBinding(Binding): data = content.get('data', None) return action, pk, data + def _hydrate(self, pk, data): + """ + Given a raw "data" section of an incoming message, returns a + DeserializedObject. + """ + s_data = [ + { + "pk": pk, + "model": self.model_label, + "fields": data, + } + ] + # TODO: Avoid the JSON roundtrip by using encoder directly? + return list(serializers.deserialize("json", json.dumps(s_data)))[0] + + def create(self, data): + self._hydrate(None, data).save() + + def update(self, pk, data): + instance = self.model.objects.get(pk=pk) + hydrated = self._hydrate(pk, data) + for name in data.keys(): + setattr(instance, name, getattr(hydrated.object, name)) + instance.save() + class WebsocketBindingDemultiplexer(JsonWebsocketConsumer): """ From d9e8fb703241e200d3709e4445c3a0039a462571 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Mon, 18 Jul 2016 22:23:33 -0400 Subject: [PATCH 06/10] Docs updates --- docs/binding.rst | 142 +++++++++++++++++++++++++++++++++++++++ docs/getting-started.rst | 2 +- docs/index.rst | 1 + 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 docs/binding.rst diff --git a/docs/binding.rst b/docs/binding.rst new file mode 100644 index 0000000..e795d6a --- /dev/null +++ b/docs/binding.rst @@ -0,0 +1,142 @@ +Data Binding +============ + +The Channels data binding framework automates the process of tying Django +models into frontend views, such as javascript-powered website UIs. It provides +a quick and flexible way to generate messages on Groups for model changes +and to accept messages that chanes models themselves. + +The main target for the moment is WebSockets, but the framework is flexible +enough to be used over any protocol. + +What does data binding allow? +----------------------------- + +Data binding in Channels works two ways: + +* Outbound, where model changes made through Django are sent out to listening + clients. This includes creation, update and deletion of instances. + +* Inbound, where a standardised message format allow creation, update and + deletion of instances to be made by clients sending messages. + +Combined, these allow a UI to be designed that automatically updates to +reflect new values and reflects across clients. A live blog is easily done +using data binding against the post object, for example, or an edit interface +can show data live as it's edited by other users. + +It has some limitations: + +* Signals are used to power outbound binding, so if you change the values of + a model outside of Django (or use the ``.update()`` method on a QuerySet), + the signals are not triggered and the change will not be sent out. You + can trigger changes yourself, but you'll need to source the events from the + right place for your system. + +* The built-in serializers are based on the built-in Django ones and can only + handle certain field types; for more flexibility, you can plug in something + like the Django REST Framework serializers. + +Getting Started +--------------- + +A single Binding subclass will handle outbound and inbound binding for a model, +and you can have multiple bindings per model (if you want different formats +or permission checks, for example). + +You can inherit from the base Binding and provide all the methods needed, but +we'll focus on the WebSocket JSON variant here, as it's the easiest thing to +get started and likely close to what you want. Start off like this:: + + from django.db import models + from channels.binding.websockets import WebsocketBinding + + class IntegerValue(models.Model): + + name = models.CharField(max_length=100, unique=True) + value = models.IntegerField(default=0) + + class IntegerValueBinding(WebsocketBinding): + + model = IntegerValue + + def group_names(self, instance, action): + return ["intval-updates"] + + def has_permission(self, user, action, pk): + return True + +This defines a WebSocket binding - so it knows to send outgoing messages +formatted as JSON WebSocket frames - and provides the two methods you must +always provide: + +* ``group_names`` returns a list of groups to send outbound updates to based + on the model and action. For example, you could dispatch posts on different + liveblogs to groups that included the parent blog ID in the name; here, we + just use a fixed group name. + +* ``has_permission`` returns if an inbound binding update is allowed to actually + be carried out on the model. We've been very unsafe and made it always return + ``True``, but here is where you would check against either Django's or your + own permission system to see if the user is allowed that action. + +For reference, ``action`` is always one of the unicode strings ``"create"``, +``"update"`` or ``"delete"``. + +Just adding the binding like this in a place where it will be imported will +get outbound messages sending, but you still need a Consumer that will both +accept incoming binding updates and add people to the right Groups when they +connect. For that, you need the other part of the WebSocket binding module, +the demultiplexer:: + + from channels.binding.websockets import WebsocketBindingDemultiplexer + from .models import IntegerValueBinding + + class BindingConsumer(WebsocketBindingDemultiplexer): + + bindings = [ + IntegerValueBinding, + ] + + def connection_groups(self): + return ["intval-updates"] + +This class needs two things set: + +* ``bindings``, a list of Binding subclasses (the ones from before) of the + models you want this to receive messages for. The socket will take care of + looking for what model the incoming message is and giving it to the correct + Binding. + +* ``connection_groups``, a list of groups to put people in when they connect. + This should match the logic of ``group_names`` on your binding - we've used + our fixed group name again. + +Tie that into your routing and you're ready to go:: + + from channels import route_class + from .consumers import BindingConsumer + + channel_routing = [ + route_class(BindingConsumer, path="^binding/"), + ] + + +Frontend Considerations +----------------------- + +Channels is a Python library, and so does not provide any JavaScript to tie +the binding into your JavaScript (though hopefully some will appear over time). +It's not very hard to write your own; messages are all in JSON format, and +have a key of ``action`` to tell you what's happening and ``model`` with the +Django label of the model they're on. + + +Custom Serialization/Protocols +------------------------------ + +Rather than inheriting from the ``WebsocketBinding``, you can inherit directly +from the base ``Binding`` class and implement serialization and deserialization +yourself. Until proper reference documentation for this is written, we +recommend looking at the source code in ``channels/bindings/base.py``; it's +reasonably well-commented. diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 4e2838b..b52f723 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -60,7 +60,7 @@ Here's what that looks like:: "ROUTING": "myproject.routing.channel_routing", }, } -.. + :: # In routing.py diff --git a/docs/index.rst b/docs/index.rst index 6b87e62..e34db68 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,6 +31,7 @@ Contents: deploying generics routing + binding backends testing cross-compat From cbe6afff8572bc6407f61d203e78d5b7ba82db56 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Mon, 18 Jul 2016 23:12:44 -0400 Subject: [PATCH 07/10] Why not rewrite binding into multiplexers on a Monday night? --- channels/binding/base.py | 7 ++-- channels/binding/websockets.py | 62 ++++++++++++---------------------- channels/generic/websockets.py | 56 +++++++++++++++++++++++++++++- docs/binding.rst | 5 +++ 4 files changed, 86 insertions(+), 44 deletions(-) diff --git a/channels/binding/base.py b/channels/binding/base.py index 0afbdf1..9abbce6 100644 --- a/channels/binding/base.py +++ b/channels/binding/base.py @@ -120,11 +120,10 @@ class Binding(object): # Inbound binding @classmethod - def trigger_inbound(cls, message): + def trigger_inbound(cls, message, **kwargs): """ Triggers the binding to see if it will do something. - We separate out message serialization to a consumer, so this gets - native arguments. + Also acts as a consumer. """ # Late import as it touches models from django.contrib.auth.models import AnonymousUser @@ -136,6 +135,8 @@ class Binding(object): # Run incoming action self.run_action(self.action, self.pk, self.data) + consumer = trigger_inbound + def deserialize(self, message): """ Returns action, pk, data decoded from the message. pk should be None diff --git a/channels/binding/websockets.py b/channels/binding/websockets.py index 27f6563..811f712 100644 --- a/channels/binding/websockets.py +++ b/channels/binding/websockets.py @@ -3,7 +3,7 @@ import json from django.core import serializers from .base import Binding -from ..generic.websockets import JsonWebsocketConsumer +from ..generic.websockets import JsonWebsocketConsumer, WebsocketDemultiplexer class WebsocketBinding(Binding): @@ -26,19 +26,24 @@ class WebsocketBinding(Binding): model = None + # Optional stream multiplexing + + stream = None + # Outbound def serialize(self, instance, action): + payload = { + "action": action, + "pk": instance.pk, + "data": self.serialize_data(instance), + } + # Encode for the stream + assert self.stream is not None + payload = WebsocketDemultiplexer.encode(self.stream, payload) + # Return WS format message return { - "text": json.dumps({ - "model": "%s.%s" % ( - instance._meta.app_label.lower(), - instance._meta.object_name.lower(), - ), - "action": action, - "pk": instance.pk, - "data": self.serialize_data(instance), - }), + "text": json.dumps(payload), } def serialize_data(self, instance): @@ -51,10 +56,13 @@ class WebsocketBinding(Binding): # Inbound def deserialize(self, message): - content = json.loads(message['text']) - action = content['action'] - pk = content.get('pk', None) - data = content.get('data', None) + """ + You must hook this up behind a Deserializer, so we expect the JSON + already dealt with. + """ + action = message['action'] + pk = message.get('pk', None) + data = message.get('data', None) return action, pk, data def _hydrate(self, pk, data): @@ -81,29 +89,3 @@ class WebsocketBinding(Binding): for name in data.keys(): setattr(instance, name, getattr(hydrated.object, name)) instance.save() - - -class WebsocketBindingDemultiplexer(JsonWebsocketConsumer): - """ - Allows you to combine multiple Bindings as one websocket consumer. - Subclass and provide a custom list of Bindings. - """ - - http_user = True - warn_if_no_match = True - bindings = None - - def receive(self, content): - # Sanity check - if self.bindings is None: - raise ValueError("Demultiplexer has no bindings!") - # Find the matching binding - model_label = content['model'] - triggered = False - for binding in self.bindings: - if binding.model_label == model_label: - binding.trigger_inbound(self.message) - triggered = True - # At least one of them should have fired. - if not triggered and self.warn_if_no_match: - raise ValueError("No binding found for model %s" % model_label) diff --git a/channels/generic/websockets.py b/channels/generic/websockets.py index 38504f0..b002680 100644 --- a/channels/generic/websockets.py +++ b/channels/generic/websockets.py @@ -1,6 +1,6 @@ import json -from ..channel import Group +from ..channel import Group, Channel from ..auth import channel_session_user_from_http from ..sessions import enforce_ordering from .base import BaseConsumer @@ -155,3 +155,57 @@ class JsonWebsocketConsumer(WebsocketConsumer): def group_send(self, name, content): super(JsonWebsocketConsumer, self).group_send(name, json.dumps(content)) + + +class WebsocketDemultiplexer(JsonWebsocketConsumer): + """ + JSON-understanding WebSocket consumer subclass that handles demultiplexing + streams using a "stream" key in a top-level dict and the actual payload + in a sub-dict called "payload". This lets you run multiple streams over + a single WebSocket connection in a standardised way. + + Incoming messages on streams are mapped into a custom channel so you can + just tie in consumers the normal way. The reply_channels are kept so + sessions/auth continue to work. Payloads must be a dict at the top level, + so they fulfill the Channels message spec. + + Set a mapping from streams to channels in the "mapping" key. We make you + whitelist channels like this to allow different namespaces and for security + reasons (imagine if someone could inject straight into websocket.receive). + """ + + mapping = {} + + def receive(self, content, **kwargs): + # Check the frame looks good + if isinstance(content, dict) and "stream" in content and "payload" in content: + # Match it to a channel + stream = content['stream'] + if stream in self.mapping: + # Extract payload and add in reply_channel + payload = content['payload'] + if not isinstance(payload, dict): + raise ValueError("Multiplexed frame payload is not a dict") + payload['reply_channel'] = self.message['reply_channel'] + # Send it onto the new channel + Channel(self.mapping[stream]).send(payload) + else: + raise ValueError("Invalid multiplexed frame received (stream not mapped)") + else: + raise ValueError("Invalid multiplexed frame received (no channel/payload key)") + + def send(self, stream, payload): + super(WebsocketDemultiplexer, self).send(self.encode(stream, payload)) + + def group_send(self, name, stream, payload): + super(WebsocketDemultiplexer, self).group_send(name, self.encode(stream, payload)) + + @classmethod + def encode(cls, stream, payload): + """ + Encodes stream + payload for outbound sending. + """ + return { + "stream": stream, + "payload": payload, + } diff --git a/docs/binding.rst b/docs/binding.rst index e795d6a..20eae46 100644 --- a/docs/binding.rst +++ b/docs/binding.rst @@ -1,6 +1,11 @@ Data Binding ============ +.. warning:: + + The Data Binding part is new and might change slightly in the + upcoming weeks, and so don't consider this API totally stable yet. + The Channels data binding framework automates the process of tying Django models into frontend views, such as javascript-powered website UIs. It provides a quick and flexible way to generate messages on Groups for model changes From 4370f043f7cbf5ab5b563b9ba9a3620cc7957750 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Mon, 18 Jul 2016 23:24:28 -0400 Subject: [PATCH 08/10] Make group_send/demultiplex encode classmethods --- channels/binding/websockets.py | 11 ++++------- channels/generic/websockets.py | 23 +++++++++++++---------- docs/generics.rst | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/channels/binding/websockets.py b/channels/binding/websockets.py index 811f712..812b1ed 100644 --- a/channels/binding/websockets.py +++ b/channels/binding/websockets.py @@ -8,7 +8,8 @@ from ..generic.websockets import JsonWebsocketConsumer, WebsocketDemultiplexer class WebsocketBinding(Binding): """ - Websocket-specific outgoing binding subclass that uses JSON encoding. + Websocket-specific outgoing binding subclass that uses JSON encoding + and the built-in JSON/WebSocket multiplexer. To implement outbound, implement: - group_names, which returns a list of group names to send to @@ -26,7 +27,7 @@ class WebsocketBinding(Binding): model = None - # Optional stream multiplexing + # Stream multiplexing name stream = None @@ -40,11 +41,7 @@ class WebsocketBinding(Binding): } # Encode for the stream assert self.stream is not None - payload = WebsocketDemultiplexer.encode(self.stream, payload) - # Return WS format message - return { - "text": json.dumps(payload), - } + return WebsocketDemultiplexer.encode(self.stream, payload) def serialize_data(self, instance): """ diff --git a/channels/generic/websockets.py b/channels/generic/websockets.py index b002680..3f086ae 100644 --- a/channels/generic/websockets.py +++ b/channels/generic/websockets.py @@ -98,11 +98,12 @@ class WebsocketConsumer(BaseConsumer): else: raise ValueError("You must pass text or bytes") - def group_send(self, name, text=None, bytes=None): + @classmethod + def group_send(cls, name, text=None, bytes=None): if text is not None: - Group(name, channel_layer=self.message.channel_layer).send({"text": text}) + Group(name).send({"text": text}) elif bytes is not None: - Group(name, channel_layer=self.message.channel_layer).send({"bytes": bytes}) + Group(name).send({"bytes": bytes}) else: raise ValueError("You must pass text or bytes") @@ -153,8 +154,9 @@ class JsonWebsocketConsumer(WebsocketConsumer): """ super(JsonWebsocketConsumer, self).send(text=json.dumps(content)) - def group_send(self, name, content): - super(JsonWebsocketConsumer, self).group_send(name, json.dumps(content)) + @classmethod + def group_send(cls, name, content): + WebsocketConsumer.group_send(name, json.dumps(content)) class WebsocketDemultiplexer(JsonWebsocketConsumer): @@ -195,17 +197,18 @@ class WebsocketDemultiplexer(JsonWebsocketConsumer): raise ValueError("Invalid multiplexed frame received (no channel/payload key)") def send(self, stream, payload): - super(WebsocketDemultiplexer, self).send(self.encode(stream, payload)) + self.message.reply_channel.send(self.encode(stream, payload)) - def group_send(self, name, stream, payload): - super(WebsocketDemultiplexer, self).group_send(name, self.encode(stream, payload)) + @classmethod + def group_send(cls, name, stream, payload): + Group(name).send(cls.encode(stream, payload)) @classmethod def encode(cls, stream, payload): """ Encodes stream + payload for outbound sending. """ - return { + return {"text": json.dumps({ "stream": stream, "payload": payload, - } + })} diff --git a/docs/generics.rst b/docs/generics.rst index 326f0c0..8231a5f 100644 --- a/docs/generics.rst +++ b/docs/generics.rst @@ -175,6 +175,40 @@ that will do this for you if you call it explicitly. ``self.close()`` is also provided to easily close the WebSocket from the server end once you are done with it. +.. _multiplexing: + +WebSocket Multiplexing +---------------------- + +Channels provides a standard way to multiplex different data streams over +a single WebSocket, called a ``Demultiplexer``. You use it like this:: + + from channels.generic.websockets import WebsocketDemultiplexer + + class Demultiplexer(WebsocketDemultiplexer): + + mapping = { + "intval": "binding.intval", + "stats": "internal.stats", + } + +It expects JSON-formatted WebSocket frames with two keys, ``stream`` and +``payload``, and will match the ``stream`` against the mapping to find a +channel name. It will then forward the message onto that channel while +preserving ``reply_channel``, so you can hook consumers up to them directly +in the ``routing.py`` file, and use authentication decorators as you wish. + +You cannot use class-based consumers this way as the messages are no +longer in WebSocket format, though. If you need to do operations on +``connect`` or ``disconnect``, override those methods on the ``Demultiplexer`` +itself (you can also provide a ``connection_groups`` method, as it's just +based on the JSON WebSocket generic consumer). + +The :doc:`data binding ` code will also send out messages to clients +in the same format, and you can encode things in this format yourself by +using the ``WebsocketDemultiplexer.encode`` class method. + + Sessions and Users ------------------ From 6f7449d8fbba840902a116c645163a8686096cdf Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Mon, 18 Jul 2016 23:34:43 -0400 Subject: [PATCH 09/10] More docs updates for multiplexing --- docs/binding.rst | 54 +++++++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/binding.rst b/docs/binding.rst index 20eae46..b78b1cb 100644 --- a/docs/binding.rst +++ b/docs/binding.rst @@ -3,7 +3,7 @@ Data Binding .. warning:: - The Data Binding part is new and might change slightly in the + Data Binding is new and might change slightly in the upcoming weeks, and so don't consider this API totally stable yet. The Channels data binding framework automates the process of tying Django @@ -64,6 +64,7 @@ get started and likely close to what you want. Start off like this:: class IntegerValueBinding(WebsocketBinding): model = IntegerValue + stream = "intval" def group_names(self, instance, action): return ["intval-updates"] @@ -86,55 +87,56 @@ always provide: own permission system to see if the user is allowed that action. For reference, ``action`` is always one of the unicode strings ``"create"``, -``"update"`` or ``"delete"``. +``"update"`` or ``"delete"``. You also supply the :ref:`multiplexing` +stream name to provide to the client - you must use multiplexing if you +use WebSocket data binding. Just adding the binding like this in a place where it will be imported will get outbound messages sending, but you still need a Consumer that will both accept incoming binding updates and add people to the right Groups when they -connect. For that, you need the other part of the WebSocket binding module, -the demultiplexer:: +connect. The WebSocket binding classes use the standard :ref:`multiplexing`, +so you just need to use that:: - from channels.binding.websockets import WebsocketBindingDemultiplexer - from .models import IntegerValueBinding + from channels.generic.websockets import WebsocketDemultiplexer - class BindingConsumer(WebsocketBindingDemultiplexer): + class Demultiplexer(WebsocketDemultiplexer): - bindings = [ - IntegerValueBinding, - ] + mapping = { + "intval": "binding.intval", + } def connection_groups(self): return ["intval-updates"] -This class needs two things set: +As well as the standard stream-to-channel mapping, you also need to set +``connection_groups``, a list of groups to put people in when they connect. +This should match the logic of ``group_names`` on your binding - we've used +our fixed group name again. -* ``bindings``, a list of Binding subclasses (the ones from before) of the - models you want this to receive messages for. The socket will take care of - looking for what model the incoming message is and giving it to the correct - Binding. - -* ``connection_groups``, a list of groups to put people in when they connect. - This should match the logic of ``group_names`` on your binding - we've used - our fixed group name again. - -Tie that into your routing and you're ready to go:: +Tie that into your routing, and tie each demultiplexed channel into the +``.consumer`` attribute of the Binding, and you're ready to go:: from channels import route_class from .consumers import BindingConsumer + from .models import IntegerValueBinding channel_routing = [ route_class(BindingConsumer, path="^binding/"), + route("binding.intval", IntegerValueBinding.consumer), ] Frontend Considerations ----------------------- -Channels is a Python library, and so does not provide any JavaScript to tie -the binding into your JavaScript (though hopefully some will appear over time). -It's not very hard to write your own; messages are all in JSON format, and -have a key of ``action`` to tell you what's happening and ``model`` with the -Django label of the model they're on. +You can use the standard Channels WebSocket wrapper **(not yet available)** +to automatically run demultiplexing, and then tie the events you receive into +your frontend framework of choice based on ``action``, ``pk`` and ``data``. + +.. note:: + + Common plugins for data binding against popular JavaScript frameworks are + wanted; if you're interested, please get in touch. Custom Serialization/Protocols From f1e8eb66e6f19b1323d138e5230c7066b1f136e2 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Tue, 19 Jul 2016 07:29:36 -0400 Subject: [PATCH 10/10] Remove unused import --- channels/binding/websockets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/channels/binding/websockets.py b/channels/binding/websockets.py index 812b1ed..2cff4ec 100644 --- a/channels/binding/websockets.py +++ b/channels/binding/websockets.py @@ -3,7 +3,7 @@ import json from django.core import serializers from .base import Binding -from ..generic.websockets import JsonWebsocketConsumer, WebsocketDemultiplexer +from ..generic.websockets import WebsocketDemultiplexer class WebsocketBinding(Binding):