mirror of
https://github.com/django/daphne.git
synced 2025-07-11 08:22:17 +03:00
Replace linearize with the more useful enforce_ordering.
This commit is contained in:
parent
38c6df8125
commit
69186ef7b7
|
@ -1,30 +0,0 @@
|
||||||
import functools
|
|
||||||
|
|
||||||
|
|
||||||
def linearize(func):
|
|
||||||
"""
|
|
||||||
Makes sure the contained consumer does not run at the same time other
|
|
||||||
consumers are running on messages with the same reply_channel.
|
|
||||||
|
|
||||||
Required if you don't want weird things like a second consumer starting
|
|
||||||
up before the first has exited and saved its session. Doesn't guarantee
|
|
||||||
ordering, just linearity.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("Not yet reimplemented")
|
|
||||||
|
|
||||||
@functools.wraps(func)
|
|
||||||
def inner(message, *args, **kwargs):
|
|
||||||
# Make sure there's a reply channel
|
|
||||||
if not message.reply_channel:
|
|
||||||
raise ValueError(
|
|
||||||
"No reply_channel in message; @linearize can only be used on messages containing it."
|
|
||||||
)
|
|
||||||
# TODO: Get lock here
|
|
||||||
pass
|
|
||||||
# OK, keep going
|
|
||||||
try:
|
|
||||||
return func(message, *args, **kwargs)
|
|
||||||
finally:
|
|
||||||
# TODO: Release lock here
|
|
||||||
pass
|
|
||||||
return inner
|
|
|
@ -3,10 +3,26 @@ import hashlib
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.sessions.backends.base import CreateError
|
||||||
|
|
||||||
|
from .exceptions import ConsumeLater
|
||||||
from .handler import AsgiRequest
|
from .handler import AsgiRequest
|
||||||
|
|
||||||
|
|
||||||
|
def session_for_reply_channel(reply_channel):
|
||||||
|
"""
|
||||||
|
Returns a session object tied to the reply_channel unicode string
|
||||||
|
passed in as an argument.
|
||||||
|
"""
|
||||||
|
# We hash the whole reply channel name and add a prefix, to fit inside 32B
|
||||||
|
reply_name = reply_channel
|
||||||
|
hashed = hashlib.md5(reply_name.encode("utf8")).hexdigest()
|
||||||
|
session_key = "chn" + hashed[:29]
|
||||||
|
# Make a session storage
|
||||||
|
session_engine = import_module(settings.SESSION_ENGINE)
|
||||||
|
return session_engine.SessionStore(session_key=session_key)
|
||||||
|
|
||||||
|
|
||||||
def channel_session(func):
|
def channel_session(func):
|
||||||
"""
|
"""
|
||||||
Provides a session-like object called "channel_session" to consumers
|
Provides a session-like object called "channel_session" to consumers
|
||||||
|
@ -17,30 +33,24 @@ def channel_session(func):
|
||||||
"""
|
"""
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def inner(message, *args, **kwargs):
|
def inner(message, *args, **kwargs):
|
||||||
|
# Make sure there's NOT a channel_session already
|
||||||
|
if hasattr(message, "channel_session"):
|
||||||
|
return func(message, *args, **kwargs)
|
||||||
# Make sure there's a reply_channel
|
# Make sure there's a reply_channel
|
||||||
if not message.reply_channel:
|
if not message.reply_channel:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"No reply_channel sent to consumer; @channel_session " +
|
"No reply_channel sent to consumer; @channel_session " +
|
||||||
"can only be used on messages containing it."
|
"can only be used on messages containing it."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Make sure there's NOT a channel_session already
|
|
||||||
if hasattr(message, "channel_session"):
|
|
||||||
raise ValueError("channel_session decorator wrapped inside another channel_session decorator")
|
|
||||||
|
|
||||||
# Turn the reply_channel into a valid session key length thing.
|
|
||||||
# We take the last 24 bytes verbatim, as these are the random section,
|
|
||||||
# and then hash the remaining ones onto the start, and add a prefix
|
|
||||||
reply_name = message.reply_channel.name
|
|
||||||
hashed = hashlib.md5(reply_name[:-24].encode()).hexdigest()[:8]
|
|
||||||
session_key = "skt" + hashed + reply_name[-24:]
|
|
||||||
# Make a session storage
|
|
||||||
session_engine = import_module(settings.SESSION_ENGINE)
|
|
||||||
session = session_engine.SessionStore(session_key=session_key)
|
|
||||||
# If the session does not already exist, save to force our
|
# If the session does not already exist, save to force our
|
||||||
# session key to be valid.
|
# session key to be valid.
|
||||||
|
session = session_for_reply_channel(message.reply_channel.name)
|
||||||
if not session.exists(session.session_key):
|
if not session.exists(session.session_key):
|
||||||
|
try:
|
||||||
session.save(must_create=True)
|
session.save(must_create=True)
|
||||||
|
except CreateError:
|
||||||
|
# Session wasn't unique, so another consumer is doing the same thing
|
||||||
|
raise ConsumeLater()
|
||||||
message.channel_session = session
|
message.channel_session = session
|
||||||
# Run the consumer
|
# Run the consumer
|
||||||
try:
|
try:
|
||||||
|
@ -52,6 +62,47 @@ def channel_session(func):
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_ordering(func=None, slight=False):
|
||||||
|
"""
|
||||||
|
Enforces either slight (order=0 comes first, everything else isn't ordered)
|
||||||
|
or strict (all messages exactly ordered) ordering against a reply_channel.
|
||||||
|
|
||||||
|
Uses sessions to track ordering.
|
||||||
|
|
||||||
|
You cannot mix slight ordering and strict ordering on a channel; slight
|
||||||
|
ordering does not write to the session after the first message to improve
|
||||||
|
performance.
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
@channel_session
|
||||||
|
@functools.wraps(func)
|
||||||
|
def inner(message, *args, **kwargs):
|
||||||
|
# Make sure there's an order
|
||||||
|
if "order" not in message.content:
|
||||||
|
raise ValueError(
|
||||||
|
"No `order` value in message; @enforce_ordering " +
|
||||||
|
"can only be used on messages containing it."
|
||||||
|
)
|
||||||
|
order = int(message.content['order'])
|
||||||
|
# See what the current next order should be
|
||||||
|
next_order = message.channel_session.get("__channels_next_order", 0)
|
||||||
|
if order == next_order or (slight and next_order > 0):
|
||||||
|
# Message is in right order. Maybe persist next one?
|
||||||
|
if order == 0 or not slight:
|
||||||
|
message.channel_session["__channels_next_order"] = order + 1
|
||||||
|
# Run consumer
|
||||||
|
return func(message, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
# Bad ordering
|
||||||
|
print("Bad ordering detected: next %s, us %s, %s" % (next_order, order, message.reply_channel))
|
||||||
|
raise ConsumeLater()
|
||||||
|
return inner
|
||||||
|
if func is not None:
|
||||||
|
return decorator(func)
|
||||||
|
else:
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def http_session(func):
|
def http_session(func):
|
||||||
"""
|
"""
|
||||||
Wraps a HTTP or WebSocket connect consumer (or any consumer of messages
|
Wraps a HTTP or WebSocket connect consumer (or any consumer of messages
|
||||||
|
@ -69,6 +120,9 @@ def http_session(func):
|
||||||
"""
|
"""
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def inner(message, *args, **kwargs):
|
def inner(message, *args, **kwargs):
|
||||||
|
# Make sure there's NOT a http_session already
|
||||||
|
if hasattr(message, "http_session"):
|
||||||
|
return func(message, *args, **kwargs)
|
||||||
try:
|
try:
|
||||||
# We want to parse the WebSocket (or similar HTTP-lite) message
|
# We want to parse the WebSocket (or similar HTTP-lite) message
|
||||||
# to get cookies and GET, but we need to add in a few things that
|
# to get cookies and GET, but we need to add in a few things that
|
||||||
|
@ -78,9 +132,6 @@ def http_session(func):
|
||||||
request = AsgiRequest(message)
|
request = AsgiRequest(message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError("Cannot parse HTTP message - are you sure this is a HTTP consumer? %s" % e)
|
raise ValueError("Cannot parse HTTP message - are you sure this is a HTTP consumer? %s" % e)
|
||||||
# Make sure there's NOT a http_session already
|
|
||||||
if hasattr(message, "http_session"):
|
|
||||||
raise ValueError("http_session decorator wrapped inside another http_session decorator")
|
|
||||||
# Make sure there's a session key
|
# Make sure there's a session key
|
||||||
session_key = request.GET.get("session_key", None)
|
session_key = request.GET.get("session_key", None)
|
||||||
if session_key is None:
|
if session_key is None:
|
||||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import unicode_literals
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from .exceptions import ConsumeLater
|
||||||
from .message import Message
|
from .message import Message
|
||||||
from .utils import name_that_thing
|
from .utils import name_that_thing
|
||||||
|
|
||||||
|
@ -43,5 +44,7 @@ class Worker(object):
|
||||||
self.callback(channel, message)
|
self.callback(channel, message)
|
||||||
try:
|
try:
|
||||||
consumer(message)
|
consumer(message)
|
||||||
|
except ConsumeLater:
|
||||||
|
self.channel_layer.send(channel, content)
|
||||||
except:
|
except:
|
||||||
logger.exception("Error processing message with consumer %s:", name_that_thing(consumer))
|
logger.exception("Error processing message with consumer %s:", name_that_thing(consumer))
|
||||||
|
|
|
@ -381,7 +381,12 @@ use if the key is missing). Keys are unicode strings.
|
||||||
|
|
||||||
The one common key across all protocols is ``reply_channel``, a way to indicate
|
The one common key across all protocols is ``reply_channel``, a way to indicate
|
||||||
the client-specific channel to send responses to. Protocols are generally
|
the client-specific channel to send responses to. Protocols are generally
|
||||||
encouraged to have one message type and one reply channel to ensure ordering.
|
encouraged to have one message type and one reply channel type to ensure ordering.
|
||||||
|
|
||||||
|
A ``reply_channel`` should be unique per connection. If the protocol in question
|
||||||
|
can have any server service a response - e.g. a theoretical SMS protocol - it
|
||||||
|
should not have ``reply_channel`` attributes on messages, but instead a separate
|
||||||
|
top-level outgoing channel.
|
||||||
|
|
||||||
Messages are specified here along with the channel names they are expected
|
Messages are specified here along with the channel names they are expected
|
||||||
on; if a channel name can vary, such as with reply channels, the varying
|
on; if a channel name can vary, such as with reply channels, the varying
|
||||||
|
@ -390,7 +395,7 @@ the format the ``new_channel`` callable takes.
|
||||||
|
|
||||||
There is no label on message types to say what they are; their type is implicit
|
There is no label on message types to say what they are; their type is implicit
|
||||||
in the channel name they are received on. Two types that are sent on the same
|
in the channel name they are received on. Two types that are sent on the same
|
||||||
channel, such as HTTP responses and server pushes, are distinguished apart
|
channel, such as HTTP responses and response chunks, are distinguished apart
|
||||||
by their required fields.
|
by their required fields.
|
||||||
|
|
||||||
|
|
||||||
|
@ -630,6 +635,8 @@ Keys:
|
||||||
for this server as a unicode string, and ``port`` is the integer listening port.
|
for this server as a unicode string, and ``port`` is the integer listening port.
|
||||||
Optional, defaults to ``None``.
|
Optional, defaults to ``None``.
|
||||||
|
|
||||||
|
* ``order``: The integer value ``0``.
|
||||||
|
|
||||||
|
|
||||||
Receive
|
Receive
|
||||||
'''''''
|
'''''''
|
||||||
|
@ -647,6 +654,9 @@ Keys:
|
||||||
|
|
||||||
* ``text``: Unicode string of frame content, if it was text mode, or ``None``.
|
* ``text``: Unicode string of frame content, if it was text mode, or ``None``.
|
||||||
|
|
||||||
|
* ``order``: Order of this frame in the WebSocket stream, starting
|
||||||
|
at 1 (``connect`` is 0).
|
||||||
|
|
||||||
One of ``bytes`` or ``text`` must be non-``None``.
|
One of ``bytes`` or ``text`` must be non-``None``.
|
||||||
|
|
||||||
|
|
||||||
|
@ -665,6 +675,9 @@ Keys:
|
||||||
format ``websocket.send.?``. Cannot be used to send at this point; provided
|
format ``websocket.send.?``. Cannot be used to send at this point; provided
|
||||||
as a way to identify the connection only.
|
as a way to identify the connection only.
|
||||||
|
|
||||||
|
* ``order``: Order of the disconnection relative to the incoming frames'
|
||||||
|
``order`` values in ``websocket.receive``.
|
||||||
|
|
||||||
|
|
||||||
Send/Close
|
Send/Close
|
||||||
''''''''''
|
''''''''''
|
||||||
|
@ -736,6 +749,33 @@ Keys:
|
||||||
* ``data``: Byte string of UDP datagram payload.
|
* ``data``: Byte string of UDP datagram payload.
|
||||||
|
|
||||||
|
|
||||||
|
Protocol Format Guidelines
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
Message formats for protocols should follow these rules, unless
|
||||||
|
a very good performance or implementation reason is present:
|
||||||
|
|
||||||
|
* ``reply_channel`` should be unique per logical connection, and not per
|
||||||
|
logical client.
|
||||||
|
|
||||||
|
* If the protocol has server-side state, entirely encapsulate that state in
|
||||||
|
the protocol server; do not require the message consumers to use an external
|
||||||
|
state store.
|
||||||
|
|
||||||
|
* If the protocol has low-level negotiation, keepalive or other features,
|
||||||
|
handle these within the protocol server and don't expose them in ASGI
|
||||||
|
messages.
|
||||||
|
|
||||||
|
* If the protocol has guaranteed ordering, ASGI messages should include an
|
||||||
|
``order`` field (0-indexed) that preserves the ordering as received by the
|
||||||
|
protocol server (or as sent by the client, if available). This ordering should
|
||||||
|
span all message types emitted by the client - for example, a connect message
|
||||||
|
might have order ``0``, and the first two frames order ``1`` and ``2``.
|
||||||
|
|
||||||
|
* If the protocol is datagram-based, one datagram should equal one ASGI message
|
||||||
|
(unless size is an issue)
|
||||||
|
|
||||||
|
|
||||||
Approximate Global Ordering
|
Approximate Global Ordering
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|
||||||
|
|
|
@ -259,7 +259,7 @@ name in the path of your WebSocket request (we'll ignore auth for now - that's n
|
||||||
|
|
||||||
# In consumers.py
|
# In consumers.py
|
||||||
from channels import Group
|
from channels import Group
|
||||||
from channels.decorators import channel_session
|
from channels.sessions import channel_session
|
||||||
|
|
||||||
# Connected to websocket.connect
|
# Connected to websocket.connect
|
||||||
@channel_session
|
@channel_session
|
||||||
|
@ -342,7 +342,7 @@ chat to people with the same first letter of their username::
|
||||||
|
|
||||||
# In consumers.py
|
# In consumers.py
|
||||||
from channels import Channel, Group
|
from channels import Channel, Group
|
||||||
from channels.decorators import channel_session
|
from channels.sessions import channel_session
|
||||||
from channels.auth import http_session_user, channel_session_user, transfer_user
|
from channels.auth import http_session_user, channel_session_user, transfer_user
|
||||||
|
|
||||||
# Connected to websocket.connect
|
# Connected to websocket.connect
|
||||||
|
@ -401,7 +401,7 @@ have a ChatMessage model with ``message`` and ``room`` fields::
|
||||||
|
|
||||||
# In consumers.py
|
# In consumers.py
|
||||||
from channels import Channel
|
from channels import Channel
|
||||||
from channels.decorators import channel_session
|
from channels.sessions import channel_session
|
||||||
from .models import ChatMessage
|
from .models import ChatMessage
|
||||||
|
|
||||||
# Connected to chat-messages
|
# Connected to chat-messages
|
||||||
|
@ -445,14 +445,16 @@ command run via ``cron``. If we wanted to write a bot, too, we could put its
|
||||||
listening logic inside the ``chat-messages`` consumer, as every message would
|
listening logic inside the ``chat-messages`` consumer, as every message would
|
||||||
pass through it.
|
pass through it.
|
||||||
|
|
||||||
Linearization
|
|
||||||
-------------
|
Enforcing Ordering
|
||||||
|
------------------
|
||||||
|
|
||||||
There's one final concept we want to introduce you to before you go on to build
|
There's one final concept we want to introduce you to before you go on to build
|
||||||
sites with Channels - linearizing consumers.
|
sites with Channels - consmer ordering
|
||||||
|
|
||||||
Because Channels is a distributed system that can have many workers, by default
|
Because Channels is a distributed system that can have many workers, by default
|
||||||
it's entirely feasible for a WebSocket interface server to send out a ``connect``
|
it just processes messages in the order the workers get them off the queue.
|
||||||
|
It's entirely feasible for a WebSocket interface server to send out a ``connect``
|
||||||
and a ``receive`` message close enough together that a second worker will pick
|
and a ``receive`` message close enough together that a second worker will pick
|
||||||
up and start processing the ``receive`` message before the first worker has
|
up and start processing the ``receive`` message before the first worker has
|
||||||
finished processing the ``connect`` worker.
|
finished processing the ``connect`` worker.
|
||||||
|
@ -464,53 +466,65 @@ same effect if someone tried to request a view before the login view had finishe
|
||||||
processing, but there you're not expecting that page to run after the login,
|
processing, but there you're not expecting that page to run after the login,
|
||||||
whereas you'd naturally expect ``receive`` to run after ``connect``.
|
whereas you'd naturally expect ``receive`` to run after ``connect``.
|
||||||
|
|
||||||
But, of course, Channels has a solution - the ``linearize`` decorator. Any
|
Channels has a solution - the ``enforce_ordering`` decorator. All WebSocket
|
||||||
handler decorated with this will use locking to ensure it does not run at the
|
messages contain an ``order`` key, and this decorator uses that to make sure that
|
||||||
same time as any other view with ``linearize`` **on messages with the same reply channel**.
|
messages are consumed in the right order, in one of two modes:
|
||||||
That means your site will happily multitask with lots of different people's messages,
|
|
||||||
but if two happen to try to run at the same time for the same client, they'll
|
|
||||||
be deconflicted.
|
|
||||||
|
|
||||||
There's a small cost to using ``linearize``, which is why it's an optional
|
* Slight ordering: Message 0 (``websocket.connect``) is done first, all others
|
||||||
decorator, but generally you'll want to use it for most session-based WebSocket
|
are unordered
|
||||||
|
|
||||||
|
* Strict ordering: All messages are consumed strictly in sequence
|
||||||
|
|
||||||
|
The decorator uses ``channel_session`` to keep track of what numbered messages
|
||||||
|
have been processed, and if a worker tries to run a consumer on an out-of-order
|
||||||
|
message, it raises the ``ConsumeLater`` exception, which puts the message
|
||||||
|
back on the channel it came from and tells the worker to work on another message.
|
||||||
|
|
||||||
|
There's a cost to using ``enforce_ordering``, which is why it's an optional
|
||||||
|
decorator, and the cost is much greater in *strict* mode than it is in
|
||||||
|
*slight* mode. Generally you'll want to use *slight* mode for most session-based WebSocket
|
||||||
and other "continuous protocol" things. Here's an example, improving our
|
and other "continuous protocol" things. Here's an example, improving our
|
||||||
first-letter-of-username chat from earlier::
|
first-letter-of-username chat from earlier::
|
||||||
|
|
||||||
# In consumers.py
|
# In consumers.py
|
||||||
from channels import Channel, Group
|
from channels import Channel, Group
|
||||||
from channels.decorators import channel_session, linearize
|
from channels.sessions import channel_session, enforce_ordering
|
||||||
from channels.auth import http_session_user, channel_session_user, transfer_user
|
from channels.auth import http_session_user, channel_session_user, channel_session_user_from_http
|
||||||
|
|
||||||
# Connected to websocket.connect
|
# Connected to websocket.connect
|
||||||
@linearize
|
@enforce_ordering(slight=True)
|
||||||
@channel_session
|
@channel_session_user_from_http
|
||||||
@http_session_user
|
|
||||||
def ws_add(message):
|
def ws_add(message):
|
||||||
# Copy user from HTTP to channel session
|
|
||||||
transfer_user(message.http_session, message.channel_session)
|
|
||||||
# Add them to the right group
|
# Add them to the right group
|
||||||
Group("chat-%s" % message.user.username[0]).add(message.reply_channel)
|
Group("chat-%s" % message.user.username[0]).add(message.reply_channel)
|
||||||
|
|
||||||
# Connected to websocket.keepalive
|
|
||||||
# We don't linearize as we know this will happen a decent time after add
|
|
||||||
@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
|
# Connected to websocket.receive
|
||||||
@linearize
|
@enforce_ordering(slight=True)
|
||||||
@channel_session_user
|
@channel_session_user
|
||||||
def ws_message(message):
|
def ws_message(message):
|
||||||
Group("chat-%s" % message.user.username[0]).send(message.content)
|
Group("chat-%s" % message.user.username[0]).send(message.content)
|
||||||
|
|
||||||
# Connected to websocket.disconnect
|
# Connected to websocket.disconnect
|
||||||
# We don't linearize as even if this gets an empty session, the group
|
@enforce_ordering(slight=True)
|
||||||
# will auto-discard after the expiry anyway.
|
|
||||||
@channel_session_user
|
@channel_session_user
|
||||||
def ws_disconnect(message):
|
def ws_disconnect(message):
|
||||||
Group("chat-%s" % message.user.username[0]).discard(message.reply_channel)
|
Group("chat-%s" % message.user.username[0]).discard(message.reply_channel)
|
||||||
|
|
||||||
|
Slight ordering does mean that it's possible for a ``disconnect`` message to
|
||||||
|
get processed before a ``receive`` message, but that's fine in this case;
|
||||||
|
the client is disconnecting anyway, they don't care about those pending messages.
|
||||||
|
|
||||||
|
Strict ordering is the default as it's the most safe; to use it, just call
|
||||||
|
the decorator without arguments::
|
||||||
|
|
||||||
|
@enforce_ordering
|
||||||
|
def ws_message(message):
|
||||||
|
...
|
||||||
|
|
||||||
|
Generally, the performance (and safety) of your ordering is tied to your
|
||||||
|
session backend's performance. Make sure you choose session backend wisely
|
||||||
|
if you're going to rely heavily on ``enforce_ordering``.
|
||||||
|
|
||||||
|
|
||||||
Next Steps
|
Next Steps
|
||||||
----------
|
----------
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
|
from channels.sessions import enforce_ordering
|
||||||
|
|
||||||
|
|
||||||
|
#@enforce_ordering(slight=True)
|
||||||
|
def ws_connect(message):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
#@enforce_ordering(slight=True)
|
||||||
def ws_message(message):
|
def ws_message(message):
|
||||||
"Echoes messages back to the client"
|
"Echoes messages back to the client"
|
||||||
message.reply_channel.send(message.content)
|
message.reply_channel.send(message.content)
|
||||||
|
|
|
@ -4,4 +4,5 @@ urlpatterns = []
|
||||||
|
|
||||||
channel_routing = {
|
channel_routing = {
|
||||||
"websocket.receive": consumers.ws_message,
|
"websocket.receive": consumers.ws_message,
|
||||||
|
"websocket.connect": consumers.ws_connect,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user