用Django、Node.js和Socket.IO创建一个实时聊天室

本文自maxburstein.com翻译而来,文章原作者为Max Burstein, 已经作者授权翻译用于非商业用途。原文地址:http://maxburstein.com/blog/realtime-django-using-nodejs-and-socketio/

曾在项目中使用python非阻塞框架eventlet来实现过相似功能,这篇文章介绍了另外一种实现实时在线交互的方式。

我们今天的目标是使用Django, Node.js, Redis和Socket.IO创建一个实时在线聊天室。虽然使用其它技术也可以轻松创建类似于聊天室的应用, 本文将展示如何把无状态的REST app(关于REST概念, 请自行google之)变为实时交互的web app, 我将用Django来创建REST部分, 你也可以使用任何你熟悉的语言和web框架来代替,闲话少说, 代码先上,对了,在上代码之前,我们有以下东东需要安装。

安装

  • Django 1.4+
  • Redis 2.6.x (somewhat optional, but recommended)
  • Redis-py 2.7.x (only needed if you're using Redis)
  • Node.js v0.8.x
  • Socket.IO v0.9.x
  • Cookie v0.0.5
  • Some sort of database or sqlite if you consider that a database

你可能已经安装了别的版本,尝试一下看可不可行,只不过我接下来的代码没有对其它版本的安装进行过测试。要是你对上面的技术一无所知,这有一个快速在Ubuntu上安装的命令列表,别的操作系统请从注释的链接进去学习怎么在你的电脑上进行安装。


#https://docs.djangoproject.com/en/dev/topics/install/
sudo apt-get install python-pip
sudo pip install django
 
#http://redis.io/download
sudo apt-get install redis-server
 
#https://github.com/andymccurdy/redis-py
sudo pip install redis    
    
#https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager
sudo apt-get install python-software-properties
sudo add-apt-repository ppa:chris-lea/node.js
sudo apt-get update
sudo apt-get install nodejs
 
#https://github.com/LearnBoost/socket.io
npm install socket.io
 
#https://github.com/shtylman/node-cookie
npm install cookie

Django 工程

让我们从这里开始吧。


django-admin.py startproject realtime_tutorial && cd realtime_tutorial
python manage.py startapp core
mkdir nodejs

好了,现在工程的目录结构已经出来了,让我们更新settings文件以便把数据库包含进来。如果你还没创建空白的数据库的话,现在请先创建一个。这里有一个我的settings.py以供参考。我添加了"core"这个app到INSTALLED_APPS配置里,同时配置好了urls和模板路径。你的设置不必跟我一模一样,但请注意要把必要的app都放在INSTALLED_APPS内。

模型(Model)

这个Model非常简单,只有两个属性,一个是user关联到用户,另外一个text是用户的发言。如果你想添加更多的属性,比如跟聊天室相关的,也是可以,但我这里为了紧扣主题,只简单设计了两个属性。


from django.db import models
from django.contrib.auth.models import User
 
class Comments(models.Model):
    user = models.ForeignKey(User)
    text = models.CharField(max_length=255)

接下来通过syncdb来把Model对应的表创建到数据库中,同时你可以建几个用户以备后用。


python manage.py syncdb
python manage.py createsuperuser

用Socket.IO创建Node服务

这部份开始,我们就进入激动人心的实时发送和接收消息编码了。我们先用Node.js来建立服务端,然后Socket.IO和Redis来干粗活。在nodejs目录下创建一个文件"chat.js",把下面的代码粘帖进去:


var http = require('http');
var server = http.createServer().listen(4000);
var io = require('socket.io').listen(server);
var cookie_reader = require('cookie');
var querystring = require('querystring');
 
var redis = require('socket.io/node_modules/redis');
var sub = redis.createClient();
 
//Subscribe to the Redis chat channel
sub.subscribe('chat');
 
//Configure socket.io to store cookie set by Django
io.configure(function(){
    io.set('authorization', function(data, accept){
        if(data.headers.cookie){
            data.cookie = cookie_reader.parse(data.headers.cookie);
            return accept(null, true);
        }
        return accept('error', false);
    });
    io.set('log level', 1);
});
 
io.sockets.on('connection', function (socket) {
    
    //Grab message from Redis and send to client
    sub.on('message', function(channel, message){
        socket.send(message);
    });
    
    //Client is sending message through socket.io
    socket.on('send_message', function (message) {
        values = querystring.stringify({
            comment: message,
            sessionid: socket.handshake.cookie['sessionid'],
        });
        
        var options = {
            host: 'localhost',
            port: 3000,
            path: '/node_api',
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Content-Length': values.length
            }
        };
        
        //Send message to Django server
        var req = http.get(options, function(res){
            res.setEncoding('utf8');
            
            //Print out error message
            res.on('data', function(message){
                if(message != 'Everything worked :)'){
                    console.log('Message: ' + message);
                }
            });
        });
        
        req.write(values);
        req.end();
    });
});

在上面的代码中,我们创建了一个端口为4000的http服务,然后我们订阅Redis的"chat"频道。我们可以简单地把这称之为"rabblerabble"(这词真难翻译啊,大致是不停地重复的意思?), 像在Django的View里的发布端一样。

然后我们设置Socket.IO, 让它可以使用Django所配置的cookie,通过socket.handshake.cookie['the_key_we_want']来访问Django的cookie。这是我们怎么获取用户session的手段。

完成以上步骤之后,我们可以处理一些事件了。第一个事件关于Redis pubsub channel的。当订阅者(subscriber)发现有新消息,把新消息发送给所有网站上的客户端。

另外一个事件是通过Socket.IO发送消息。我们使用querystring创建一个查询并发送给Django服务。在本例中,Django服务运行在3000端口上,你也可以任意你想要的端口。路径是/node_api(接下来我们会在Django urls里配置这个地址)。我们朝这个地址发送querystring,Django应该保存并返回"Everything worked :)",若没有,请检查Node输出的错误日志以解决。

一定要用Redis吗?

在本工程中如果你不想使用Redis,是完全可行的,我想使用它的原因是发现这样有助于学习。如果你不想使用Redis作为路由,可以使用类似Express这样的库。在上面的代码中实现了这么一个功能:Django接收并保存消息,然后通过Socket.IO广播给所有客户端。

模板文件

模板包括了所有的HTML和javascript代码,用于显示消息和跟Node服务交互。


<!DOCTYPE html>
<html>
 <head>
  <title>Realtime Django</title>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <script src="//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js" type="text/javascript"></script>
  <script src="http://localhost:4000/socket.io/socket.io.js"></script>
  <script>
    $(document).ready(function(){
      var socket = io.connect('localhost', {port: 4000});

      socket.on('connect', function(){
        console.log("connect");
      });

      var entry_el = $('#comment');

      socket.on('message', function(message) {
        //Escape HTML characters
        var data = message.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");

        //Append message to the bottom of the list
        $('#comments').append('<li>' + data + '</li>');
        window.scrollBy(0, 10000000000);
        entry_el.focus();
      });

      entry_el.keypress(function(event){
        //When enter is pressed send input value to node server
        if(event.keyCode != 13) return;
        var msg = entry_el.attr('value');
        if(msg){
           socket.emit('send_message', msg, function(data){
                console.log(data);
           });

        //Clear input value
        entry_el.attr('value', '');
       }
      });
    });
  </script>
 </head>
 <body>
  <ul id="comments">
    {% for comment in comments %}
   <li>{{comment.user}}: {{comment.text}}</li> {% endfor %}
  </ul>
  <input type="text" id="comment" name="comment" />
 </body>
</html>

在JavaScript客户端代码中,我们先用Socket.IO连接Node服务(本例是localhost的4000端口)。从服务端得到内容后,解码并呈现在消息列表里。发送消息时只要在输入框里按下回车键,服务器收到之后先保存,然后触发"message"事件。

底部的"comments"变量来自我们在下一步要创建Django的视图输出,第一次加载页面的时候循环输出列表,以后新的消息每次从Node服务获取并叠加之。

The View(Django的View,好像没有规范的中文叫法,“视图”?)

打开realtime_tutorial/core/views.py并粘帖代码:


from core.models import Comments, User
 
from django.shortcuts import render
from django.http import HttpResponse, HttpResponseServerError
from django.views.decorators.csrf import csrf_exempt
from django.contrib.sessions.models import Session
from django.contrib.auth.decorators import login_required
 
import redis
 
@login_required
def home(request):
    comments = Comments.objects.select_related().all()[0:100]
    return render(request, 'index.html', locals())
 
@csrf_exempt
def node_api(request):
    try:
        #Get User from sessionid
        session = Session.objects.get(session_key=request.POST.get('sessionid'))
        user_id = session.get_decoded().get('_auth_user_id')
        user = User.objects.get(id=user_id)
 
        #Create comment
        Comments.objects.create(user=user, text=request.POST.get('comment'))
        
        #Once comment has been created post it to the chat channel
        r = redis.StrictRedis(host='localhost', port=6379, db=0)
        r.publish('chat', user.username + ': ' + request.POST.get('comment'))
        
        return HttpResponse("Everything worked :)")
    except Exception, e:
        return HttpResponseServerError(str(e))

让我们看下这个文件里的View都干了些什么。"home" 是一个标准的view, 我在初始化的时候就使用select_related在读取comment时同时把用户名(username)也取出来,而不是每读取一个comment对象时再去读取单个用户,减少数据库交互次数(有关这方面的知识,更详细请参考这里)。

第二个view是用来接收Node app发送的数据的。我们从POST的数据中获取用户的sessionid,并通过它得到用户id,一旦验证用户通过便可以创建comment了。现在可以发送用户名和comment给Redis服务了。我们把这些数据发送到之前命名为"chat"的pubsub channel。

urls配置

简单起见,登录登出我们都使用了Django内置的view,并且对应使用了Django框架内置的admin登录界面模板。


from django.conf.urls import patterns, include, url
 
urlpatterns = patterns('',
    url(r'^$', 'core.views.home', name='home'),
    url(r'^node_api$', 'core.views.node_api', name='node_api'),
    url(r'^login/$', 'django.contrib.auth.views.login', {'template_name': 'admin/login.html'}, name='login'),
    url(r'^logout/$', 'django.contrib.auth.views.logout', {'next_page': '/'}, name='logout'),
)

启动之

好了,万事具备,只欠启动命令。在终端输入以下命令启动以下服务,在启动Node的命令前面那行注释的意思是,在启动Django之后,你要另外打开一个终端窗口来启动Node:


python manage.py runserver localhost:3000
 
#In a new terminal tab cd into the nodejs directory we created earlier
node chat.js

我已经把源码放在github上面方便你使用,如果你有兴趣的话,可以扩展用户自己创建/加入聊天室等功能。你还可以无视Django用别的后台比如PHP或者Rails来代替。

你要有任何建议或者意见,请留下评论或直接联系我。

李保银 :
正好现在项目中要做个在线客服平台,看了你的这篇文就准备用nodejs试试了,原来打算用tornado或者orbited的。
老楠 Reply to 李保银 :
要是业务逻辑不复杂,可以试试node.js,当是学习和做实验,业务复杂的我现在还真不太敢用,开发调试工具不太完善。之前在项目中用eventlet框架为主的架构来实现即时消息功能,编程比这个架构稍方便,纯python以同步的编程方式来实现异步非阻塞(异步的事情eventlet帮你做掉了)。
老楠 :
过去了这么多年,事情起了变化,用django-channels是更丝滑的选择了。
For example, "name@something.com". If someone replies to you it will be via email.
For example, "http://someaddress.com"