Django-channels实现websockets

在这个例子中,我们将使用Django Channels来创建一个实时在线应用,当用户登录或下线时,这个应用可以自动更新在线的用户列表

使用WebSockets(通过Django Channels实现)可以管理客户端和服务器端之间的通信,只要用户登录,这个事件将会广播至每个连接的用户,他们的浏览器会自动刷新页面。

运行环境:

  • Python(v3.6.0)
  • Django(v1.10.5)
  • Django Channels(v1.0.3)
  • Redis(v3.2.8)

目标

  • 通过Django Channels使Django项目支持Web sockets
  • 在Django和Redis服务器之间建立连接
  • 使用Django中的basic user authentication
  • 用户登录或登出时发出Django信号

首先创建一个使用Pyenv创建一个虚拟环境以及安装第三方模块

$ pip install django==1.10.5 channels==1.0.2 asgi_redis==1.0.0
$ django-admin.py startproject example_channels
$ cd example_channels
$ python manage.py startapp example
$ python manage.py migrate

下载和安装Docker(Mac)
在Docker中启动Redis服务docker run -p 6379:6379 -d registry.alauda.cn/library/redis:2.8

setting.py:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'channels',
    'example',
]

配置CHANNEL_LAYERS设置默认的后端和路由

CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'asgi_redis.RedisChannelLayer',
        'CONFIG': {
            'hosts': [('localhost', 6379)],
        },
        'ROUTING': 'example_channels.routing.channel_routing',
    }
}

WebSockets 101

正常情况下,Django使用HTTP请求实现客户端和服务器端的通信:

    1. 客户端发送HTTP请求到服务器端
    1. Django解析请求,提取URL,并将其和view进行匹配
    1. view处理请求并返回HTTP Response至客户端
      不同于HTTP请求,WebSockets协议使用双向直接通信,也就是说不需要客户端发送请求,服务器端就可以向发送数据。HTTP协议中,只有客户端可以发送请求和接收响应,WebSockets协议中,服务器端可以同时与多个客户端进行通信。我们将使用ws://前缀而不是http://

Consumers and Groups

创建第一个consumer,它可以处理客户端和服务端的基本连接。
example_channels/example/consumers.py:

from channels import Group


def ws_connect(message):
    Group('users').add(message.reply_channel)


def ws_disconnect(message):
    Group('users').discard(message.reply_channel)

consumer相当于django中的view,任何用户连接到我们应用都会被加入到'users'组,并且接收服务器端发送的消息。当客户端与我们的应用断开连接,这个连接通道将会'user'组中移除,并且停止接收服务器端的消息。

下一步建立路由routes,它的作用和Django URL的配置类似。
example_channels/routing.py:

from channels.routing import route
from example.consumers import ws_connect, ws_disconnect


channel_routing = [
    route('websocket.connect', ws_connect),
    route('websocket.disconnect', ws_disconnect),
]

注意到,我们现在将consumer方法和WebSockets相关联。

Templates

example_channels/example/templates/example/_base.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
  <title>Example Channels</title>
</head>
<body>
  <div class="container">
    <br>
    {% block content %}{% endblock content %}
  </div>
  <script src="//code.jquery.com/jquery-3.1.1.min.js"></script>
  {% block script %}{% endblock script %}
</body>
</html>

example_channels/example/templates/example/user_list.html

{% extends 'example/_base.html' %}

{% block content %}{% endblock content %}

{% block script %}
  <script>
    var socket = new WebSocket('ws://' + window.location.host + '/users/');

    socket.onopen = function open() {
      console.log('WebSockets connection created.');
    };

    if (socket.readyState == WebSocket.OPEN) {
      socket.onopen();
    }
  </script>
{% endblock script %}

现在客户端可以通过WebSocket与服务器创建连接。

Views

创建一个视图类来渲染和返回user_list.html:

from django.shortcuts import render


def user_list(request):
    return render(request, 'example/user_list.html')

为user_list视图类配置路由URL:
example_channels/example/urls.py:

from django.conf.urls import url
from example.views import user_list


urlpatterns = [
    url(r'^$', user_list, name='user_list'),
]

example_channels/example_channels/urls.py:

from django.conf.urls import include, url
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^', include('example.urls', namespace='example')),
]
Test

启动项目,观察控制台shell输出

[2017/02/19 23:24:57] HTTP GET / 200 [0.02, 127.0.0.1:52757]
[2017/02/19 23:24:58] WebSocket HANDSHAKING /users/ [127.0.0.1:52789]
[2017/02/19 23:25:03] WebSocket DISCONNECT /users/ [127.0.0.1:52789]

User Authentication

现在,我们已经可以通过WebSocket建立一个连接,下一步将处理用户认证模块(User Authentication)。记住:我们期望一个用户可以登录应用并且可以看到其他已经注册的用户。第一步,创建一个简单的用户登录界面:
example_channels/example/templates/example/log_in.html:

{% extends 'example/_base.html' %}

{% block content %}
  <form action="{% url 'example:log_in' %}" method="post">
    {% csrf_token %}
    {% for field in form %}
      <div>
        {{ field.label_tag }}
        {{ field }}
      </div>
    {% endfor %}
    <button type="submit">Log in</button>
  </form>
  <p>Don't have an account? <a href="{% url 'example:sign_up' %}">Sign up!</a></p>
{% endblock content %}

更新视图函数example_channels/example/views.py:

from django.contrib.auth import login, logout
from django.contrib.auth.forms import AuthenticationForm
from django.core.urlresolvers import reverse
from django.shortcuts import render, redirect


def user_list(request):
    return render(request, 'example/user_list.html')


def log_in(request):
    form = AuthenticationForm()
    if request.method == 'POST':
        form = AuthenticationForm(data=request.POST)
        if form.is_valid():
            login(request, form.get_user())
            return redirect(reverse('example:user_list'))
        else:
            print(form.errors)
    return render(request, 'example/log_in.html', {'form': form})


def log_out(request):
    logout(request)
    return redirect(reverse('example:log_in'))

Django带有支持通用认证功能的表单,我们可以使用AuthenticationForm来处理用户登录。此表单检查提供的用户名和密码,然后在找到经过验证的用户时返回一个用户对象。 我们登录验证的用户并将其重定向到我们的主页。 用户还必须能够注销应用程序,因此我们创建了一个注销视图,该视图提供了该功能,然后将用户重定向至登录页面。

更新example_channels/example/urls.py:

from django.conf.urls import url
from example.views import log_in, log_out, user_list


urlpatterns = [
    url(r'^log_in/$', log_in, name='log_in'),
    url(r'^log_out/$', log_out, name='log_out'),
    url(r'^$', user_list, name='user_list')
]

创建一个注册的HTML页面:

{% extends 'example/_base.html' %}

{% block content %}
  <form action="{% url 'example:sign_up' %}" method="post">
    {% csrf_token %}
    {% for field in form %}
      <div>
        {{ field.label_tag }}
        {{ field }}
      </div>
    {% endfor %}
    <button type="submit">Sign up</button>
    <p>Already have an account? <a href="{% url 'example:log_in' %}">Log in!</a></p>
  </form>
{% endblock content %}

增加处理注册的视图函数:

def sign_up(request):
    form = UserCreationForm()
    if request.method == 'POST':
        form = UserCreationForm(data=request.POST)
        if form.is_valid():
            form.save()
            return redirect(reverse('example:log_in'))
        else:
            print(form.errors)
    return render(request, 'example/sign_up.html', {'form': form})

为sign_up配置URL:
url(r'^sign_up/$', sign_up, name='sign_up'),

Login Alerts

我们有基本的用户认证功能,但我们仍然需要显示用户列表,并且我们需要服务器在用户登录和注销时告诉用户组。 重写consumer函数,使得在客户端连接之后和在客户端断开连接之前立即发送消息。 消息数据将包含用户的用户名和连接状态。

example_channels/example/consumers.py

import json
from channels import Group
from channels.auth import channel_session_user, channel_session_user_from_http


@channel_session_user_from_http
def ws_connect(message):
    Group('users').add(message.reply_channel)
    Group('users').send({
        'text': json.dumps({
            'username': message.user.username,
            'is_logged_in': True
        })
    })


@channel_session_user
def ws_disconnect(message):
    Group('users').send({
        'text': json.dumps({
            'username': message.user.username,
            'is_logged_in': False
        })
    })
    Group('users').discard(message.reply_channel)

我们在函数中添加了装饰器以从Django会话中获取用户。 而且,所有消息都必须是JSON序列化的,所以我们将数据转储到JSON字符串中。

example_channels/example/templates/example/user_list.html:

{% extends 'example/_base.html' %}

{% block content %}
  <a href="{% url 'example:log_out' %}">Log out</a>
  <br>
  <ul>
    {% for user in users %}
      <!-- NOTE: We escape HTML to prevent XSS attacks. -->
      <li data-username="{{ user.username|escape }}">
        {{ user.username|escape }}: {{ user.status|default:'Offline' }}
      </li>
    {% endfor %}
  </ul>
{% endblock content %}

{% block script %}
  <script>
    var socket = new WebSocket('ws://' + window.location.host + '/users/');

    socket.onopen = function open() {
      console.log('WebSockets connection created.');
    };

    socket.onmessage = function message(event) {
      var data = JSON.parse(event.data);
      // NOTE: We escape JavaScript to prevent XSS attacks.
      var username = encodeURI(data['username']);
      var user = $('li').filter(function () {
        return $(this).data('username') == username;
      });

      if (data['is_logged_in']) {
        user.html(username + ': Online');
      }
      else {
        user.html(username + ': Offline');
      }
    };

    if (socket.readyState == WebSocket.OPEN) {
      socket.onopen();
    }
  </script>
{% endblock script %}

在主页上,我们扩展用户列表以显示用户信息和在线状态。 我们将存储每个用户的用户名,以便在DOM中查找用户项。 并且还为WebSocket添加了一个事件监听器,它可以处理来自服务器的消息。 当收到消息时,解析JSON数据,找到给定用户的<li>元素,并更新该用户的状态。

Django不会记录用户是否登录,所以需要创建一个简单的模型来做这件事。 在example_channels / example / models.py中创建一个LoggedInUser模型,该模型与User模型是一对一的关系。

from django.conf import settings
from django.db import models


class LoggedInUser(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL, related_name='logged_in_user')

当用户登录的时候会创建一个LoggedInUser实例,反之用于注销时会删除一个LoggedInUser实例。

数据库迁移:

$ python manage.py makemigrations
$ python manage.py migrate

接下来,在example_channels / example / views.py中更新我们的user_list视图,以获取要呈现的用户列表:

from django.contrib.auth import get_user_model, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.core.urlresolvers import reverse
from django.shortcuts import render, redirect


User = get_user_model()


@login_required(login_url='/log_in/')
def user_list(request):
    """
    NOTE: This is fine for demonstration purposes, but this should be
    refactored before we deploy this app to production.
    Imagine how 100,000 users logging in and out of our app would affect
    the performance of this code!
    """
    users = User.objects.select_related('logged_in_user')
    for user in users:
        user.status = 'Online' if hasattr(user, 'logged_in_user') else 'Offline'
    return render(request, 'example/user_list.html', {'users': users})


def log_in(request):
    form = AuthenticationForm()
    if request.method == 'POST':
        form = AuthenticationForm(data=request.POST)
        if form.is_valid():
            login(request, form.get_user())
            return redirect(reverse('example:user_list'))
        else:
            print(form.errors)
    return render(request, 'example/log_in.html', {'form': form})


@login_required(login_url='/log_in/')
def log_out(request):
    logout(request)
    return redirect(reverse('example:log_in'))


def sign_up(request):
    form = UserCreationForm()
    if request.method == 'POST':
        form = UserCreationForm(data=request.POST)
        if form.is_valid():
            form.save()
            return redirect(reverse('example:log_in'))
        else:
            print(form.errors)
    return render(request, 'example/sign_up.html', {'form': form})

如果用户与LoggedInUser相关联,那么我们将用户的状态记录为“Online”,如果不是,则该用户是“Offline”。 我们还在我们的用户列表和注销视图中添加了@login_required装饰器,以便仅限注册用户访问。

此时,用户可以登录和注销,这将触发服务器向客户端发送消息,但我们无法知道用户首次登录时哪些用户登录。用户仅在其他用户 状态改变。 这就是LoggedInUser发挥作用的地方,但我们需要一种方式在用户登录时创建LoggedInUser实例,然后在用户注销时将其删除。

Django库有信号量的功能,当发生某些操作时它会广播通知。 应用程序可以侦听这些通知,然后对其执行操作。 我们可以利用两个有用的内置信号(user_logged_in和user_logged_out)来处理我们的LoggedInUser行为。

在example_channels/example中添加signals.py:

from django.contrib.auth import user_logged_in, user_logged_out
from django.dispatch import receiver
from example.models import LoggedInUser


@receiver(user_logged_in)
def on_user_login(sender, **kwargs):
    LoggedInUser.objects.get_or_create(user=kwargs.get('user'))


@receiver(user_logged_out)
def on_user_logout(sender, **kwargs):
    LoggedInUser.objects.filter(user=kwargs.get('user')).delete()

example_channels/example/apps.py:

from django.apps import AppConfig


class ExampleConfig(AppConfig):
    name = 'example'

    def ready(self):
        import example.signals

example_channels/example/init.py

default_app_config = 'example.apps.ExampleConfig'

------------------------------------------------EOF----------------------------------------------------

推荐阅读更多精彩内容