NodeJS與Django協同應用開發(1) —— 原型搭建


系列目錄


前文我們介紹了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端的架構設計,關于這部分的內容我們就下篇文章再說。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容