美文网首页程序员
NodeJS与Django协同应用开发(1) —— 原型搭建

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

作者: AlfredX | 来源:发表于2016-04-17 16:15 被阅读4380次

    系列目录


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

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

    基本框架

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

    初始结构.png

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

    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 流量走内网,不暴露API node.js端分发消息需要额外工作量

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

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

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

    相关文章

      网友评论

        本文标题:NodeJS与Django协同应用开发(1) —— 原型搭建

        本文链接:https://www.haomeiwen.com/subject/kkvolttx.html