NodeJS与Django协同应用开发(1) —— 原型搭建

系列目录

前文我们介绍了node.js还有socket.io的基础知识,这篇文章我们来说一下如何将node.js与Django一起使用,并且搭建一个简单的原型出来。

原本我们的项目全部都基于Django框架,并且也能够满足基本需求了,但是后来新增了实时需求,在Django框架下比较难做,为了少挖点坑,多省点时间,我们选择使用node.js。

基本框架

在没有node.js之前,我们的结构是这样的:

《NodeJS与Django协同应用开发(1) —— 原型搭建》 初始结构.png

增加的node.js系统应该是与原本的Django系统平行的,而我们使用node.js的初衷是将它作为实时需求的服务器,不承担或者只承担一小部分的业务逻辑,且完全不需要和数据库有交互。所以之后的结构就是这样的:

《NodeJS与Django协同应用开发(1) —— 原型搭建》 nodejs+django结构.png

数据库依然只有Django负责连接,这和一般的系统并没有什么区别,所以文章里就不涉及具体读写数据库的实现了。
于是问题的关键就在于django和node.js该如何交互。
Django和node.js几乎是两种风格的网络框架,语言也不同,所以我们需要一个通信手段。而系统间通信不外乎就是靠网络请求(部署在本机的不同系统不在此列,也不值得讨论),或是另一个可以用作通信的系统。通常来说对于node.js和django之间交互的话,一般有3种手段可选:

  1. HTTP Request
  2. Redis publish/subscribe
  3. RPC

三种都是可行的方案,但是也有各自的应用场景。

原型实现(1) HTTP Request

首先是http request。先来看一下django代码:

[urls.py]
from django.conf.urls import url

urlpatterns = [
    url(r'^get_data/$', 'backend.views.get_data'),
]
[backend.views.py]
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods

@require_http_methods(["GET"])
def get_data(request):
    data = {
        'data1': 123,
        'data2': 'abc',    
    }
    return JsonResponse(data, safe=False)

这里我们定义了一个叫get_data的api,方便起见我们使用JSON格式作为返回类型,返回一个整型一个字符串。

然后再来看一下node.js代码:

[django_request.js]
var http = require('http');

var default_protocol = 'http://'
var default_host = 'localhost';
var default_port = 8000;

exports.get = function get(path, on_data_callback, on_err_callback) {
    var url = default_protocol + default_host + ':' + default_port + path;
    var req = http.get(url, function onDjangoRequestGet(res) {
        res.setEncoding('utf-8');
        res.on('data', function onDjangoRequestGetData(data) {
            on_data_callback(JSON.parse(data));
        });
        res.resume();
    }).on('error', function onDjangoRequestGetError(e) {
        if (on_err_callback)
            on_err_callback(e);
        else
            throw "error get " + url + ", " + e;
    });
}
[app.js]
var django_request = require('./django_request');

django_request.get('/get_data/', function(data){
    console.log('get_data response: %j',data);
}, function(err) {
    console.log('error get_data: '+e);
});

在django_request.js里面我们写了一个通用的get方法,可以用来向django发起http get请求。运行app.js以后我们就看到结果了。

alfred@workstation:~/Documents/node_django/nodeapp$ node app.js 
get_data response: {"data1":123,"data2":"abc"}

非常简单,但是别急,还有post请求。
普通的post请求和get类似,非常简单,用过http库的同学都应该会写,但是这年头已经没有普通的post了,大家的安全意识越来越高,没有哪个网站会不防跨域请求了,所以我们的post还需要解决跨域的问题。
默认配置下django的中间件是包含CsrfViewMiddleware的,也就是会在用户访问网页时向cookie中添加csrf_token。所以我们就写一个简单的页面,顺便把socket.io也使用起来。

在django的views中添加名为post_data的api,以及为页面准备的view函数。

[backend.views.py]
import json

def index(request):
    return render_to_response('index.html', RequestContext(request, {}))

def get_post_args(request, *args):
    try:
        args_info = json.loads(request.body)
    except Exception, e:
        args_info = {}

    return [request.POST.get(item, None) or args_info.get(item, None) for item in args]
    
@require_http_methods(["POST"])
def post_data(request):
    data1, data2 = get_post_args(request, 'data1', 'data2')
    response = {
        'status': 'success',
        'data1': data1,
        'data2': data2,
    }
    return JsonResponse(response, safe=False)
[urls.py]
urlpatterns = [
    url(r'^$', 'backend.views.index'),
    url(r'^get_data/$', 'backend.views.get_data'),
    url(r'^post_data/$', 'backend.views.post_data'),
]

socket.io监听9000端口。

[app.js]
var http = require('http');
var sio = require('socket.io');
var chatroom = require('./chatroom');

var server = http.createServer();
var io = sio.listen(server, {
    log: true,
});
chatroom.init(io);
var port = 9000;
server.listen(9000, function startapp() {
    console.log('Nodejs app listening on ' + port);
});

定义通用的post方法。

[django_request.js]
var cookie = require('cookie');

exports.post = function post(user_cookie, path, values, on_data_callback, on_err_callback) {
    var cookies = cookie.parse(user_cookie);
    var values = querystring.stringify(values);
    var options = {
        hostname: default_host,
        port: default_port,
        path: path,
        method: 'POST',
        headers: {
            'Cookie': user_cookie,
            'Content-Type': 'application/x-www-form-urlencoded',
            'Content-Length': values.length,
            'X-CSRFToken': cookies['csrftoken'],
        }
    };
    var post_req = http.request(options, function onDjangoRequestPost(res) {
        res.setEncoding('utf-8');
        res.on('data', function onDjangoRequestPostData(data) {
            on_data_callback(data);
        });
    }).on('error', function onDjangoRequestPostError(e) {
        console.log(e);
        if (on_err_callback)
            on_err_callback(e);
        else
            throw "error get " + url + ", " + e;
    });
    post_req.write(values);
    post_req.end();
}

为get和post事件设定handler。

[chatroom.js]
var cookie_reader = require('cookie');
var django_request = require('./django_request');

function initSocketEvent(socket) {
    socket.on('get', function() {
        console.log('event: get');
        django_request.get('/get_data/', function(res){
            console.log('get_data response: %j',res);
        }, function(err) {
            //经指正这里应该是err而不是e,保留BUG以此为鉴
            console.log('error get_data: '+e);
        });
    });
    socket.on('post', function(data) {
        console.log('event: post');
        django_request.post(socket.handshake.headers.cookie, '/post_data/', {'data1':123, 'data2':'abc', function(res){
            console.log('post_data response: %j', res);
        }, function(err){
            console.log('error post_data: '+e);
        });
    });
};

exports.init = function(io) {
    io.on('connection', function onSocketConnection(socket) {
        console.log('new connection');
        initSocketEvent(socket);
    });
};

简单的html页面。

[index.html]
    ...
    <div>
        <button id="btn" style="width:200px;height:150px;">hit me</button>
    </div>
    <div id="content"></div>
    <script type="text/javascript" src="/static/backend/js/jquery-1.9.1.min.js"></script>
    <script type="text/javascript" src="/static/backend/js/socket.io.min.js"></script>
    <script type="text/javascript">
    (function() {
        socket = io.connect('http://localhost:9000/');
        socket.on('connect', function() {
            console.log('connected');
        });
        $('#btn').click(function() {
            socket.emit('get');
            socket.emit('post');
        });
    })();
    </script>

实现post的重点在于cookie的设置。socket.io在客户端连接的时候默认就会带上浏览器的cookie,这帮我们省去了不少功夫,也省去了显示传递csrftoken的烦恼。但是在node.js中向django发起post请求时不能只设定X-CSRFToken,也不能只设定cookie。看一下django的源码(django.middleware.csrf)就能够了解到是同时获取cookie和HTTP_X_CSRFTOKEN的。所以我们必须把cookie传给post函数,这样才能成功发起请求。
顺便一提,这同时也解决了sessionid的问题,如果是登录用户,django是能够获取到user信息的。

以上是node.js端向django端发起请求,但是这仅仅只是由node.sj主动而已,还缺少django向node.js发起HTTP请求的部分。

所以我们在app.js中添加如下代码

[app.js]
function onGetData(request, response){
    if (request.method == 'GET'){
        response.writeHead(200, {"Content-Type": "application/json"});
        jsonobj = {
            'data1': 123,
            'data2': 'abc'
        }
        response.end(JSON.stringify(jsonobj));
    } else {
        response.writeHead(403);
        response.end();
    }
}
function onPostData(request, response){
    if (request.method == 'POST'){
        var body = '';

        request.on('data', function (data) {
            body += data;

            if (body.length > 1e6)
                request.connection.destroy();
        });

        request.on('end', function () {
            var post = qs.parse(body);
            response.writeHead(200, {'Content-Type': 'application/json'});
            jsonobj = {
                'data1': 123,
                'data2': 'abc',
                'post_data': post,
            }
            response.end(JSON.stringify(jsonobj));
        });
    } else {
        response.writeHead(403);
        response.end();
    }
}

然后我们写一小段python代码来测试一下

[http_test.py]
import urllib
import urllib2
 
httpClient = None
try:
    headers = {"Content-type": "application/x-www-form-urlencoded", 
               "Accept": "text/plain"}
    data = urllib.urlencode({'post_arg1': 'def', 'post_arg2': 456})
    get_request = urllib2.Request('http://localhost:9000/node_get_data/', headers=headers)
    get_response = urllib2.urlopen(get_request)
    get_plainRes = get_response.read().decode('utf-8')
    print(get_plainRes)
    post_request = urllib2.Request('http://localhost:9000/node_post_data/', data, headers)
    post_response = urllib2.urlopen(post_request)
    post_plainRes = post_response.read().decode('utf-8')
    print(post_plainRes)
except Exception, e:
    print e

然后就能看到成功的输出:

[nodejs]
Nodejs app listening on 9000
url: /node_get_data/, method: GET
url: /node_post_data/, method: POST
[python]
{"data1":123,"data2":"abc"}
{"data1":123,"data2":"abc","post_data":{"post_arg1":"def","post_arg2":"456"}}

到此双向的HTTP Request就建立起来了。只不过node.js端并没有csrf认证。而在我们的django端,csrf认证和api都是已经部署了的线上模块,所以不需要在这方面花精力。

然而如果最终决定采用双向HTTP Reqeust的话,那node.js端的csrf认证必须要做好,因为HTTP API都是向外暴露的,这是这种方式最大的缺点。并不是所有的系统间调用都需要向公网露接口,一旦被他人知道了一些非公开的api路径,那很有可能引发安全问题。
并且HTTP是要走外网的,这还带来了一些额外的开销。

原型实现(2) Redis Publish/Subscribe

相比HTTP Request,这种方式的代码量要少的多。(关于Redis Pub/Sub,请移步相关文档
要实现双向通信,无非是两边同时建立pub与sub channel。而subscribe需要持续监听,关于这一点,我们先看代码再说。

首先是node.js端,npm安装redis库,库里已经包含了所有我们需要的了。

[app.js]
var redis = require('redis');
// subscribe
var sub = redis.createClient();
sub.subscribe('test_channel');
sub.on('message', function onSubNewMessage(channel, data) {
    console.log(channel, data);
});
// publish
var pub = redis.createClient();
pub.publish('test_channel', 'nodejs data published to test_channel');

node.js是事件驱动的异步非阻塞框架,pub/sub这种方式的实现和它本身的代码风格非常相近,所以8行代码就实现了sub与pub的功能。

再来看python代码

[redis_test.py]
import redis

r = redis.StrictRedis(host='localhost', port=6379)
# publish
r.publish('test_channel', 'python data published to test_channel');
# subscribe
sub = r.pubsub()
sub.subscribe('test_channel')
for item in sub.listen():  
    if item['type'] == 'message':  
        print(item['data'])

代码中的channel名是可以自定义的。实际应用中可以按照不同的需求管理不同的channel,这样就不会造成消息的混乱。

多看几眼代码,细心的同学会发现,python的sub代码只会执行一次,也就是说如果需要持续监听的话,至少要新开一个线程。也就是说对于django,我们还需要额外做线程间通信的工作。这种做法并不是说不可以,只是与django原本的风格不太吻合,并不是非常推荐。
(顺便一提,不要将开启线程的工作放在views函数中,因为views的执行是多线程的,线程数量会随着访问压力增大而增加,放在views中会导致重复开心线程,这个坑我爬过。)

原型实现(3) RPC

在我的另一篇文章(ZeroRPC应用)中提到过项目所使用的RPC系统。这个系统的建立是在node.js应用之前的,非常庆幸当时选用的是zerorpc,正好可以无缝接合node.js。。
类似于HTTP Request,如果要实现双向通信那就需要在两端同时建立server。
python端的代码可以看我的那篇文章里所写的内容,这边我们就来说一下node.js端的调用和建立server。

[app.js]
var zerorpc = require("zerorpc");

var client = new zerorpc.Client();
client.connect("tcp://127.0.0.1:4242");

client.invoke("test_connection", "arg1", "arg2", function(error, res, more) {
    if (!error){
        console.log(res, more);
    } else {
        console.log(error);
    }
});

var rpcserver = new zerorpc.Server({
    test_connection: function(arg1, arg2, reply) {
        reply(null, True, arg1, arg2);
    }
});

rpcserver.bind("tcp://0.0.0.0:5353");

和python一样,在node.js里写zerorpc也可以返回多个值,这就是invoke的回调函数里的more参数的作用。res表示返回的第一个值,而more包含了其他的返回值。

rpc方式的概念和HTTP Request的方式一样,不过比HTTP Request好在不需要暴露API,因为完全可以在内网下部署,并把外网端口禁封。但是他们又有一个共同的缺点,那就是对于node.js来说,我们需要一个额外的消息分发机制。为什么呢?因为我们接受消息的入口是统一的。
考虑这个情况:
在node.js里我们有2个子系统,子系统A和子系统B,他们分别为功能I和功能II服务,各自也都有需要和django交互的地方。如果此时功能I和功能II分别有一条消息到来,那我们就必须要区分消息的送达对象。这里就又是额外的工作量了。
这个情况在使用redis时就不会出现。redis下我们可以只subscribe自己关心的channel,也就是说只会收到与自身系统相关的消息。

总结

对于三种方式的优缺点,我们总结如下:

实现方式优点缺点
HTTP Request方便和现有系统集成暴露外网API,流量走外网,需要额外安全工作
Redis切合node.js风格,容易按channel名管理django端subscribe需要额外工作量
RPC流量走内网,不暴露APInode.js端分发消息需要额外工作量

工作中我们可以按照实际需求来组合使用,我的项目里原本是使用HTTP Request实现的原型,后来也是因为其暴露API的缺点以及node.js端需要csrf认证才放弃用django向node.js发起HTTP请求。

目前我们项目中django向node.js发消息使用的是redis,node.js向django请求数据或发送消息使用的是rpc。这么做没有什么额外的工作量,可以让我专注于业务逻辑。

业务逻辑涉及到node.js端的架构设计,关于这部分的内容我们就下篇文章再说。

    原文作者:AlfredX
    原文地址: https://www.jianshu.com/p/d6d4bac19865
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞